Your firewall vendor publishes a critical advisory. Days later NVD assigns a score, a PoC lands on GitHub, and CISA adds it to KEV. Correlating those sources by hand is a tooling problem, not a knowledge problem.
The ValtersIT API puts CVE records (150,000+) and a threat-IP dataset (290,000+ addresses) behind one REST interface with one authentication scheme. This guide shows five integrations end to end. Every API request and response shape below was checked against the live API, including the failures.
What you get
| Data | Details |
|---|---|
| CVE records | 150,000+. cvss, cvss_vector, epss, severity, cisa_kev (boolean), has_exploit, has_patch, poc_status, poc_url, cwe_id, mitre_tactic, shodan_dorks, affected_version, patch_details
|
| Detection content |
yara_rule, wazuh_rule, elastic_rule on paid plans where a rule exists. sigma_rule and poc_code are Pro only |
| Threat IPs | 290,000+ addresses. ip, threat_type, confidence, sources, cve_ids, malware_families, country_code, asn, org, ports, is_tor, is_vpn
|
| Data honesty |
pending_fields lists what NVD has not filled in yet, backfilled_fields lists what we filled in after the fact |
Base URL: https://api.valtersit.com/api/v1
Authentication: Authorization: Bearer <your key> on every request. Keys look like vit_v1_....
Threat IP types you can filter on: scanner, mixed, malware_dist, botnet_c2, phishing, tor, exploit_host, c2_server, spam, brute_force. Confidence scores are conservative: only a small number of IPs score 75 or above, so start with min_confidence=50.
Try it in 30 seconds
The public sandbox key needs no signup and costs nothing. It serves ten synthetic CVE records (CVE-2026-90001 to CVE-2026-90010) so you can test your parsing before you pay for anything.
# List all ten sandbox records
curl -s https://api.valtersit.com/api/v1/cve \
-H "Authorization: Bearer vit_sandbox_free_demo_key_2026" \
| jq '.data[] | {cve_id, vendor, cvss, severity, cisa_kev, poc_status}'
# One record
curl -s https://api.valtersit.com/api/v1/cve/CVE-2026-90001 \
-H "Authorization: Bearer vit_sandbox_free_demo_key_2026" \
| jq '{cve_id, vendor, cvss, severity, cisa_kev, poc_status, pending_fields}'
{
"cve_id": "CVE-2026-90001",
"vendor": "SandboxSoft Firewall Pro",
"cvss": 9.8,
"severity": "critical",
"cisa_kev": true,
"poc_status": "Public",
"pending_fields": []
}
The sandbox has no IP feed. For real data, register for a free key at api.valtersit.com and top up, or subscribe.
Ground rules used in every example
- Put the key in an environment variable:
export VIT_API_KEY=vit_v1_... -
GET /cveneeds at least one filter (vendor,severity,cvss_min,cvss_max,epss_min,has_exploit,has_patch,changed_since,published_since). Pages hold at most 100 records. (The sandbox key is exempt from the filter rule.) - Single lookups such as
GET /cve/CVE-2026-26084return the record itself, not adatawrapper. List endpoints return{"data": [...], ...}. - Errors:
401bad or missing key,402out of credits,403plan does not allow it,404unknown CVE,422bad parameter,429rate limit. Every metered response carriesX-RateLimit-LimitandX-RateLimit-Remaining. - Pay-as-you-go spends one credit per CVE record returned. Standard and Pro track every CVE you look up, and re-checking one you have already fetched is free.
Use case 1: Firewall blocklist from the threat-IP feed
Browsing the feed by criteria needs Standard or Pro (Standard returns up to 500 rows per request, Pro up to 1,000). Pay-as-you-go can look up specific addresses with ?ips=1.2.3.4,5.6.7.8 (up to 10 per request) but cannot pull the list.
Pick your filters from the data, not from hope. At the time of writing:
| Filter | IPs returned |
|---|---|
type=botnet_c2&min_confidence=50 |
about 10,800 |
type=malware_dist&min_confidence=50 |
about 31,000 |
type=scanner&min_confidence=50 |
about 125,000 (usually too broad to block outright) |
#!/usr/bin/env python3
"""Pull botnet C2 IPs and write an nftables batch file."""
import ipaddress
import os
import subprocess
import requests
API = "https://api.valtersit.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VIT_API_KEY']}"}
PAGE = 500 # Standard cap. Pro allows 1000. A larger limit is clamped, not rejected.
def fetch(threat_type: str, min_confidence: int = 50) -> list[str]:
ips, offset = [], 0
while offset is not None:
r = requests.get(
f"{API}/ip/feed",
headers=HEADERS,
timeout=30,
params={"type": threat_type, "min_confidence": min_confidence,
"limit": PAGE, "offset": offset},
)
r.raise_for_status()
body = r.json()
ips += [row["ip"] for row in body["data"]]
offset = body["next_offset"] # None on the last page
return ips
def build_batch(ips: list[str]) -> str:
v4 = [i for i in ips if ipaddress.ip_address(i).version == 4]
v6 = [i for i in ips if ipaddress.ip_address(i).version == 6]
lines = ["add table inet filter"]
for name, family, members in (("vit_block4", "ipv4_addr", v4), ("vit_block6", "ipv6_addr", v6)):
lines.append(f"add set inet filter {name} {{ type {family}; }}")
lines.append(f"flush set inet filter {name}")
for i in range(0, len(members), 1000):
chunk = ", ".join(members[i:i + 1000])
lines.append(f"add element inet filter {name} {{ {chunk} }}")
return "\n".join(lines) + "\n"
if __name__ == "__main__":
ips = sorted(set(fetch("botnet_c2")))
path = "/var/lib/vit/blocklist.nft"
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(build_batch(ips))
subprocess.run(["nft", "-f", path], check=True) # one atomic transaction
print(f"loaded {len(ips)} addresses")
Add the drop rules once, and run the script from cron (*/30 * * * *). Because it rebuilds the sets on every run, addresses that leave the feed age out on their own.
nft add rule inet filter input ip saddr @vit_block4 drop
nft add rule inet filter input ip6 saddr @vit_block6 drop
⚠️ Roll this out carefully. Threat feeds contain false positives: shared hosting, CGNAT pools and reused addresses. Start with a
logrule instead ofdrop, keep an allowlist for your own ranges and partners, and review what would have been blocked before you enforce anything. The API never returns private, loopback or reserved ranges.
pfSense / OPNsense: the API requires an authentication header, so a firewall cannot fetch the feed directly as a URL table alias. Run the fetch on any Linux host, write one address per line to a file you serve on your internal network, and point the alias at that internal URL.
Use case 2: Enrich SIEM alerts with CVE context
An IDS alert carries a CVE ID and nothing else. This helper turns it into a priority decision using fields that exist on every record.
import os
import time
import requests
API = "https://api.valtersit.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VIT_API_KEY']}"}
def enrich(cve_id: str) -> dict | None:
for attempt in range(3):
r = requests.get(f"{API}/cve/{cve_id}", headers=HEADERS, timeout=10)
if r.status_code == 429: # rate limited: wait and retry
time.sleep(2 ** attempt)
continue
if r.status_code == 404: # not in the database
return None
r.raise_for_status() # 401 / 402 / 5xx should be loud
c = r.json() # the record itself, no "data" wrapper
priority = "P1" if c["cisa_kev"] or (c["has_exploit"] and (c["cvss"] or 0) >= 9) else \
"P2" if c["has_exploit"] or (c["cvss"] or 0) >= 7 else "P3"
return {
"cve_id": c["cve_id"], "vendor": c["vendor"], "cvss": c["cvss"],
"epss": c["epss"], "cisa_kev": c["cisa_kev"], "has_exploit": c["has_exploit"],
"has_patch": c["has_patch"], "pending_fields": c["pending_fields"],
"priority": priority,
}
raise RuntimeError("rate limited after 3 attempts")
Print the result as JSON and this works as a scripted lookup, an n8n or Shuffle step, or a Splunk adaptive response action. For Elastic, the Logstash http filter does the same inline:
filter {
if [cve_id] {
http {
url => "https://api.valtersit.com/api/v1/cve/%{[cve_id]}"
headers => { "Authorization" => "Bearer ${VIT_API_KEY}" }
target_body => "vit"
}
if [vit][cvss] {
mutate {
add_field => {
"[threat][cvss]" => "%{[vit][cvss]}"
"[threat][epss]" => "%{[vit][epss]}"
"[threat][kev]" => "%{[vit][cisa_kev]}"
"[threat][exploit]" => "%{[vit][has_exploit]}"
}
}
}
}
}
Put a cache in front of the lookup (Logstash memcached filter, Redis, or a translate table). Alerts repeat the same CVE constantly, and on pay-as-you-go every fresh record costs a credit.
Use case 3: CI/CD prioritization gate
The API is not a scanner and it does not match package versions. Let your scanner (Trivy, Grype, OSV-Scanner) find the CVE IDs, then use the API to decide which ones should actually stop a build: known-exploited, exploit-available, or unpatched and severe.
#!/usr/bin/env python3
"""Fail the build on CVEs that are actively dangerous. Input: trivy JSON on stdin."""
import json
import os
import sys
import requests
API = "https://api.valtersit.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VIT_API_KEY']}"}
MAX_LOOKUPS = 50 # each new CVE costs a credit on pay-as-you-go
report = json.load(sys.stdin)
ids = sorted({v["VulnerabilityID"]
for res in report.get("Results", [])
for v in res.get("Vulnerabilities") or []
if v["VulnerabilityID"].startswith("CVE-")})[:MAX_LOOKUPS]
blocked = []
for cve_id in ids:
r = requests.get(f"{API}/cve/{cve_id}", headers=HEADERS, timeout=15)
if r.status_code == 404:
continue # unknown to us: leave it to the scanner
if r.status_code == 402:
sys.exit("::error::out of API credits, gate could not run")
r.raise_for_status()
c = r.json()
if c["cisa_kev"] or (c["has_exploit"] and not c["has_patch"] and (c["cvss"] or 0) >= 9):
blocked.append(f"{cve_id} cvss={c['cvss']} kev={c['cisa_kev']} exploit={c['has_exploit']} patch={c['has_patch']}")
if blocked:
print("::error::blocking CVEs found:")
print("\n".join(f" {b}" for b in blocked))
sys.exit(1)
print(f"gate passed ({len(ids)} CVEs checked)")
- name: CVE gate
env:
VIT_API_KEY: ${{ secrets.VIT_API_KEY }}
run: |
trivy fs --format json --quiet . | python3 scripts/cve_gate.py
You can also ask the API directly for the worst offenders of a vendor you depend on:
curl -s "https://api.valtersit.com/api/v1/cve?vendor=Fortinet&cvss_min=9&has_exploit=true&has_patch=false&limit=5" \
-H "Authorization: Bearer $VIT_API_KEY" | jq '.total_count, (.data[] | {cve_id, cvss})'
Use case 4: Change tracking
CVE data matures over days: NVD scores arrive late, patches appear, a PoC goes public. Three tools cover it.
Poll for changes. changed_since takes an ISO-8601 date or datetime. Store the time of your last run and ask for everything newer:
curl -s "https://api.valtersit.com/api/v1/cve?vendor=Microsoft&changed_since=2026-09-18T00:00:00Z&limit=50" \
-H "Authorization: Bearer $VIT_API_KEY" \
| jq '.total_count, (.data[] | {cve_id, cvss, has_exploit, updated_at})'
Use Z for UTC. A +02:00 offset must be URL-encoded as %2B02:00, because a bare + in a query string turns into a space and the API answers 422.
Read pending_fields. Every record says which values are still missing, so you know when a result is provisional:
"pending_fields": ["license_model", "origin_country"]
Re-check a record later and the list shrinks. backfilled_fields reports values we filled in after you paid for the record.
Push instead of poll (Pro). A watchlist entry sends a webhook when a matching CVE is published or updated:
curl -s -X POST https://api.valtersit.com/api/v1/watchlist \
-H "Authorization: Bearer $VIT_API_KEY" -H "Content-Type: application/json" \
-d '{"vendors": ["Fortinet"], "min_severity": "high", "webhook_url": "https://example.com/hooks/cve"}'
Your endpoint receives a POST with cve_id, vendor, cvss, summary and url. Webhook URLs must be public http(s) endpoints (private and internal addresses are refused), up to 50 entries per account. Delivery is best-effort: a failed POST is not retried, so treat the webhook as a nudge and use changed_since as your source of truth.
Use case 5: Detection rules and PoC status in one call
import os
import requests
r = requests.get("https://api.valtersit.com/api/v1/cve/CVE-2026-26084",
headers={"Authorization": f"Bearer {os.environ['VIT_API_KEY']}"}, timeout=15)
r.raise_for_status()
c = r.json()
print(c["cve_id"], c["vendor"], "cvss", c["cvss"], "| PoC:", c["poc_status"], c["poc_url"] or "")
for field, ext in (("yara_rule", "yar"), ("wazuh_rule", "xml"), ("elastic_rule", "txt")):
if c.get(field):
with open(f"{c['cve_id']}.{ext}", "w") as f:
f.write(c[field])
print("wrote", f"{c['cve_id']}.{ext}")
if c["sigma_rule_locked"]:
print("a Sigma rule exists for this CVE but needs the Pro plan")
Not every CVE has every rule, so always check for null. Treat every rule as a starting point: read it, test it against known-good traffic in a lab, and only then deploy. poc_status and poc_url are on every plan. The poc_code field (sanitized exploit code where publicly available) and sigma_rule are Pro only.
Quick reference
| Endpoint | Purpose |
|---|---|
GET /cve |
Search CVEs (at least one filter, up to 100 per page) |
GET /cve/{id} |
One CVE record |
GET /ip/feed |
Threat IPs: type, min_confidence, country, cve, changed_since, ips, limit, offset
|
GET /ip/{ip} |
One IP (404 if unknown, 400 for private ranges) |
POST /watchlist, GET /watchlist
|
Webhook alerts (Pro) |
GET /history |
CVEs you have looked up, with change flags |
GET /usage |
Credits and rate-limit status |
GET /status |
Public service status |
| Plan | Credits / month | Rate limit | API keys | IP feed |
|---|---|---|---|---|
| Pay-as-you-go | top-up, €0.08 per credit | 10 / min | 1 | look up specific IPs (ips=, up to 10 per request) |
| Standard | 5,000 | 30 / min | 1 | full browsing, 500 rows per request |
| Pro | 20,000 | 100 / min | 5 | full browsing, 1,000 rows per request, plus proprietary sources |
💡 Founding Member pricing — ends 31 Dec 2026. Standard €19/mo (list €29) and Pro €65/mo (list €99), kept for as long as your subscription stays active. See pricing →
Top comments (0)