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.
https://api.voretix.com
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.
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.
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:
# 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_keyor403 invalid_api_key.
Sign in and create your API key on the profile page. Copy it right away — it is shown only once.
POST /api/v1/scans with the target URL. Only url is required; everything else takes the same defaults as the website form.
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);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.
POST /scans. Free-plan allowance — paid plans raise it. Resets on the 1st, 00:00 UTC./search, full reports (/scans/{id}) and /related share this daily pool./quota and /health never spend any stored budget — poll freely.Which endpoints spend which budget
| Budget | Endpoints | Resets |
|---|---|---|
| Monthly scans (plan) | POST /scans | 1st of the month, 00:00 UTC |
| Hunt — one account allowance by plan: 50 / 100 / 500 / 2000 per day; anonymous website use is Free per IP | GET /api/v1/hunt · website hunt.php | Daily, 00:00 UTC |
| Search — 1000/day | GET /search · GET /scans/{id} · GET /scans/{id}/related | Daily, 00:00 UTC |
| Comments — 1000/day | GET/POST /scans/{id}/comments · GET /comments · POST /scans/{id}/report | Daily, 00:00 UTC |
| Status tier — burst only, 1000/min | GET /scans/{id}/status · GET /scans/options · GET /hunt/filters · GET /me/stats · GET /quota · PUT /scans/{id}/visibility · GET /health | Sliding 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:
| Header | Meaning |
|---|---|
X-RateLimit-Scope | Which budget the numbers describe: hunt-daily, search-daily, comments-daily or scans-monthly. |
X-RateLimit-Limit | That budget's total allowance. |
X-RateLimit-Remaining | Requests left in that budget. |
X-RateLimit-Reset | Unix timestamp of that budget's next reset. |
Retry-After | On any 429: seconds to wait before retrying. |
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: 1783036800The 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.
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.
Every scan has one of three visibility levels, chosen when you schedule it and changeable later by its owner via PUT /scans/{id}/visibility:
404 scan_not_found — exactly like a scan that never existed — so scan ids cannot be probed through the API./healthAnswers 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"
}/api/v1/quotaEverything 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"
}/api/v1/me/statsVerdict 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"
}
}/api/v1/scansQueues 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
urlhttps:// is assumed. See the validation rules below.visibilitylocationosbrowserlanguageAccept-Language for the visit.en-USen-GBes-ESfr-FRde-DEit-ITpt-BRja-JPzh-CNru-RUtortor browser profile. Default: falseURL validation
- Only
httpandhttpsURLs; strict RFC 3986 character set (no whitespace, quotes or angle brackets), valid percent-encoding. - No credentials embedded in the URL (
user:pass@hostis 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"
}
}/api/v1/scans/optionsMachine-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
{
"ok": true,
"endpoint": "POST /api/v1/scans",
"notes": [
"Send a JSON object; `url` is the only required field.",
"Omitted fields take the defaults below (same as the website form).",
"`tor` is a boolean and independent of choosing the tor browser.",
"Visibility: public scans are listed in search; hidden scans are viewable by anyone with the link but never listed; private scans are only visible to your account."
],
"fields": {
"visibility": { "allowed": ["public", "private", "hidden"], "default": "public" },
"location": { "allowed": ["random", "at", "au", "be", "ca", "de", "fr", "hk", "it", "jp", "pl", "ro", "se", "sg", "uk", "us-dallas", "us-los-angeles", "us-arlington", "us-cheyenne"], "default": "random" },
"os": { "allowed": ["windows", "linux", "android", "ios"], "default": "windows" },
"browser": { "allowed": ["chrome", "firefox", "edge", "safari", "brave", "opera", "tor"], "default": "chrome" },
"language": { "allowed": ["en-US", "en-GB", "es-ES", "fr-FR", "de-DE", "it-IT", "pt-BR", "ja-JP", "zh-CN", "ru-RU"], "default": "en-US" }
},
"tor": { "allowed": [true, false], "default": false },
"url": { "max_chars": 2000, "schemes": ["http", "https"] },
"quota": {
"monthly_limit": 500,
"used_this_month": 18,
"remaining": 482,
"resets_at": "2026-08-01T00:00:00+00:00"
}
}/api/v1/scans/{scan_id}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_idA–Z a–z 0–9 . _ -).Document sections
| Key | Contents |
|---|---|
scan | The scheduled request: target URL, timestamp, configuration, numeric completed/public plus their readable status and visibility labels. |
result | Analysis 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_info | WHOIS & DNS: registrar (+ abuse contacts), owner organization/country, hashed owner email, domain age, DNSSEC, and the nameservers array. |
technologies | summary (counts and scanned sources) and detected — each technology with name, version, confidence, categories and evidence. |
content_summary | AI-generated summary of the page text, with input/output size counters. |
heuristics | YARA-style engine header (rules loaded, matched count, max severity) and matches — each rule with severity, category, description, tags, files and matched strings. |
ai | AI verdict: verdict, confidence (0–100), targeting_brand and the reasoning. |
fingerprint | 128-bit structural SimHash, split into simhash_high/simhash_low — the value the similarity hunts use. |
page_state | The rendered page: title, body text sample, viewport, scroll metrics, image counts and the anti-bot challenge flag. |
main_document | The main HTTP response: status, MIME, server, timing, response headers, and the full TLS certificate details (issuer, subject, validity, SANs). |
redirects | Every redirect hop with from/to URL, status, peer IP and headers, in order. |
assets | Every fetched subresource with URL, type, MIME, kind, status and SHA-256/SHA-1/MD5 hashes. |
links | Navigable links found on the page, classified internal/external, with anchor text. |
images | Loaded <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)
{
"ok": true,
"scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
"found": true,
"stored": true,
"is_owner": true,
"scan": {
"scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
"target_url": "https://suspicious-site.example/login",
"timestamp": "2026-07-07T14:52:09",
"completed": 1,
"public": 1,
"location": "random",
"platform": "windows",
"browser": "chrome",
"tor": 0,
"language": "en-US",
"status": "completed",
"visibility": "public"
},
"result": {
"final_url": "https://suspicious-site.example/account/login",
"analysis_started_at": "2026-07-07T14:52:31.114210",
"analysis_completed_at": "2026-07-07T14:53:18.902547",
"resolved_ip_address": "203.0.113.7",
"api_received_at": "2026-07-07T14:53:20.001318",
"screenshot_sha256": "9f2c41d0a35b6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7",
"screenshot_top_sha256": "d31a08b94c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7",
"html_sha256": "5b77e2c14c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7",
"screenshot_url": "https://cdn.voretix.com/images/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70.jpg"
},
"domain_info": {
"host": "suspicious-site.example",
"domain_registered": "yes",
"domain_creation_date": "2026-06-28",
"domain_age_in_days": 9,
"registrar": "NameCheap, Inc.",
"registrar_abuse_email": "[email protected]",
"owner_organization": "Privacy service provided by Withheld for Privacy ehf",
"owner_country": "IS",
"owner_email_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"owner_email_domain": "withheldforprivacy.com",
"whois_data_hidden": 1,
"dnssec": "unsigned",
"nameservers": [
{ "position": 0, "name": "dns1.registrar-servers.com", "ipv4": "156.154.132.200", "ipv6": null }
]
},
"technologies": {
"summary": { "detected_count": 3, "src_html_files": 1, "src_js_files": 6, "src_css_files": 2, "src_response_headers": 14, "src_network_report": 1 },
"detected": [
{ "position": 0, "name": "Bootstrap", "version": "4.6.0", "confidence": 100, "categories": ["UI frameworks"], "evidence": ["css: bootstrap.min.css"] }
]
},
"content_summary": {
"summary": "A sign-in page asking for account credentials and card details…",
"method": "ai",
"input_words": 794,
"truncated": 0
},
"heuristics": {
"matches": [
{ "position": 0, "rule": "phishkit_login_form", "severity": 80, "category": "credentials", "description": "Login form posting credentials to an external host", "tags": ["phishing"], "files": ["page_dump.html"], "strings": ["$form_action"] }
]
},
"ai": {
"verdict": "malicious",
"confidence": 96,
"targeting_brand": "PayPal",
"reason": "The page imitates the PayPal sign-in flow on a 9-day-old domain and posts credentials to a third-party endpoint."
},
"fingerprint": {
"algorithm": "simhash",
"bits": 128,
"simhash_high": 13835058055282163712,
"simhash_low": 4611686018427387904
},
"page_state": {
"title": "PayPal — Log in to your account",
"body_text_sample": "Log in to your PayPal account Email address Password …",
"inner_width": 1280,
"inner_height": 800,
"images_total": 6,
"images_complete": 6,
"images_broken": 0
},
"main_document": {
"status": 200,
"mime_type": "text/html",
"response_time_ms": 341.87,
"remote_ip_address": "203.0.113.7",
"remote_port": 443,
"protocol": "h2",
"server": "cloudflare",
"headers": { "content-type": "text/html; charset=UTF-8", "server": "cloudflare" },
"tls_protocol": "TLS 1.3",
"tls_cipher": "AES_128_GCM",
"tls_issuer": "WE1",
"tls_subject_name": "suspicious-site.example",
"tls_valid_from": "2026-07-01T00:00:00",
"tls_valid_to": "2026-09-29T23:59:59",
"tls_san_list": ["suspicious-site.example", "*.suspicious-site.example"]
},
"redirects": [
{ "position": 0, "from_url": "http://suspicious-site.example/login", "to_url": "https://suspicious-site.example/account/login", "status": 301, "protocol": "http/1.1", "remote_ip_address": "203.0.113.7", "remote_port": 80, "headers": { "location": "https://suspicious-site.example/account/login" } }
],
"assets": [
{ "position": 0, "url": "https://suspicious-site.example/static/kit.js", "resource_type": "Script", "mime_type": "application/javascript", "kind": "js", "status": 200, "response_time_ms": 88.4, "sha256": "7ab3f1e24c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7", "sha1": "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12", "md5": "9e107d9d372bb6826bd81d3542a419d6" }
],
"links": [
{ "position": 0, "url": "https://t.me/support_helpdesk", "kind": "external", "text": "Contact support" }
],
"images": [
{ "position": 0, "url": "https://suspicious-site.example/img/pp-logo.svg", "alt": "PayPal", "width": 124, "height": 32 }
]
}is_owner tells you whether the presented key's account scheduled this scan./api/v1/scans/{scan_id}/statusLightweight 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"
}
}/api/v1/scans/{scan_id}/visibilitySwitch 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
visibilityRequest
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"
}
}/api/v1/scans/{scan_id}/commentsThe 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_idA–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"
}
}/api/v1/scans/{scan_id}/commentsAdds 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
urlscan.target_url of the report (or the url the scan was scheduled with). Must match, or the request answers 409 url_mismatch.comment<>, {}, [], 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"
}
}/api/v1/commentsEverything 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&, ? 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"
}
}/api/v1/scans/{scan_id}/reportFlags 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
reasonmessageother, 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"
}
}/api/v1/searchPaginated search with the exact semantics of the website's Search Scans page. Scope public sees public scans only (hidden scans are link-only by design, private scans are private); scope mine sees your own scans whatever their visibility.
Query parameters
q% and _ match literally.scopepublic = public scans; mine = your own scans, any visibility.publicmineverdictverdict=malicious&verdict=suspicious). Unrated scans count as clean. Omit for all.malicioussuspiciouscleanriskhigh ≥ 70, medium 40–69, low < 40.anyhighmediumlowdays0 = any time; max 365. Default: 0sortpage1per_page50Each result carries a score (0–100): the strongest engine signal for that scan — the higher of the AI confidence and the top heuristic severity, with verdict-based defaults when neither engine reported a number.
Request
curl -s "https://api.voretix.com/api/v1/search?q=paypal&verdict=malicious&risk=high&days=30&sort=score-desc" \
-H "X-API-Key: vx_YOUR_KEY"import requests
r = requests.get(
"https://api.voretix.com/api/v1/search?q=paypal&verdict=malicious&risk=high&days=30&sort=score-desc",
headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())const res = await fetch("https://api.voretix.com/api/v1/search?q=paypal&verdict=malicious&risk=high&days=30&sort=score-desc", {
headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());Response
{
"ok": true,
"scope": "public",
"total": 128,
"page": 1,
"pages": 3,
"per_page": 50,
"results": [
{
"scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
"url": "https://suspicious-site.example/login",
"host": "suspicious-site.example",
"ip": "203.0.113.7",
"timestamp": "2026-07-07T14:52:09",
"score": 96,
"verdict": { "key": "malicious", "label": "Malicious" },
"visibility": "public"
}
],
"quota": {
"monthly_limit": 500,
"used_this_month": 18,
"remaining": 482,
"resets_at": "2026-08-01T00:00:00+00:00"
}
}/api/v1/huntPivot 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.
*_containsfilters 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_moreis true, passcursor=<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
limit50cursornext_cursor.max_distancesimhash / similar_to filters, 0–32. Default: 8Request — 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
{
"ok": true,
"query": {
"filters": [
{ "filter": "brand", "value": "PayPal" },
{ "filter": "domain_age_max_days", "value": "30" }
],
"limit": 50,
"cursor": null,
"max_distance": 8
},
"count": 50,
"has_more": true,
"next_cursor": "0197d1f0-8a2b-7c4d-9e6f-5a3b2c1d0e9f",
"results": [
{
"scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
"target_url": "https://suspicious-site.example/login",
"timestamp": "2026-07-07T14:52:09",
"completed": 1,
"final_url": "https://suspicious-site.example/account/login",
"resolved_ip_address": "203.0.113.7",
"host": "suspicious-site.example",
"registrar": "NameCheap, Inc.",
"ai_verdict": "malicious",
"ai_confidence": 96,
"targeting_brand": "PayPal",
"title": "PayPal — Log in to your account",
"completed_status": "completed"
}
],
"quota": {
"monthly_limit": 500,
"used_this_month": 18,
"remaining": 482,
"resets_at": "2026-08-01T00:00:00+00:00"
}
}simhash or similar_to, each result row gains a simhash_distance column — the Hamming distance to your probe, 0 meaning structurally identical./api/v1/hunt/filtersSelf-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)
{
"ok": true,
"endpoint": "/api/v1/hunt",
"notes": [
"Only public scans are searchable; private and hidden scans never appear in hunt results.",
"All filters combine with AND; a repeated filter must hold for every value.",
"*_contains filters are case-insensitive substring matches (3-200 chars); LIKE wildcards in the value are treated literally.",
"Results are newest-first. Page by passing `cursor` = the last scan_id of the previous page.",
"At most 15 filter conditions per request.",
"Each hunt spends one request of the account-wide daily hunt budget shared with signed-in website use (yours: 50/day), with an extra API burst cap of 30 hunts per minute."
],
"modifiers": {
"limit": "Page size, 1..200 (default 50).",
"cursor": "Only return scans older than this scan_id (keyset pagination).",
"max_distance": "Hamming-distance ceiling for simhash / similar_to, 0..32 (default 8)."
},
"filter_count": 72,
"filters": [
{
"name": "url_contains",
"group": "scan",
"description": "Substring of the submitted URL or the final URL after redirects.",
"example": "url_contains=wp-login"
}
],
"quota": {
"monthly_limit": 500,
"used_this_month": 18,
"remaining": 482,
"resets_at": "2026-08-01T00:00:00+00:00"
}
}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.
?url_contains=wp-login?url_path=/account/verify?redirect_url_contains=bit.ly?scanned_after=2026-06-01?scanned_before=2026-07-01T12:00:00?completed=completed?location=de?platform=Windows?browser=Chrome?language=en-US?tor=1?ip=203.0.113.7?http_status=403?server_contains=nginx?header_contains=x-powered-by?mime_type=text/html?host=login.example.com?host_contains=paypal?registrar=NameCheap, Inc.?registrar_contains=namecheap?owner_organization_contains=privacy?owner_country=US?owner_email_domain=protonmail.com?owner_email_hash=<sha256>?whois_hidden=1?dnssec=unsigned?domain_created_after=2026-05-01?domain_created_before=2026-06-30?domain_age_max_days=30?domain_age_min_days=3650?nameserver_contains=cloudflare?nameserver_ip=198.51.100.53?tls_issuer_contains=Let's Encrypt?tls_subject_contains=example?tls_san_contains=.example.com?tls_protocol=TLS 1.3?title=Sign in to your account?title_contains=verify?dom_text_contains=your account has been limited?summary_contains=credential?has_challenge_text=1?link_url_contains=t.me/?link_text_contains=unlock account?image_url_contains=logo?image_alt_contains=bank?technology=WordPress?technology_contains=cpanel?technology_version=5.8.1?technology_category_contains=CDN?verdict=malicious?brand=PayPal?brand_contains=micro?ai_confidence_min=80?ai_reason_contains=fake login?rule=phishkit_login_form?rule_contains=phishkit?heuristic_category=credentials?heuristic_severity_min=70?heuristic_tag_contains=obfuscation?yara_similar_to=<scan_id>?asset_sha256=<64 hex>?asset_sha1=<40 hex>?asset_md5=<32 hex>?asset_url_contains=/kit/config.js?asset_kind=js?asset_mime_type=application/zip?screenshot_sha256=<64 hex>?html_sha256=<64 hex>?shares_assets_with=<scan_id>?simhash=<32 hex>?similar_to=<scan_id>Branch on error, not on message. Statuses group naturally: 4xx means fix the request (or wait, for 429); 5xx means retry later with backoff.
| Code | HTTP | When |
|---|---|---|
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.