API documentation
Integrate real-time threat intelligence into your security workflows
Threat Radar API v1
The Threat Radar API provides programmatic access to real-time threat intelligence data from multiple sources. Integrate threat monitoring, vulnerability scanning, and security alerts directly into your applications, SIEM systems, or security workflows.
Quick start
- Sign in at radar.offseq.com/console with your email address
- Enter the one-time code we email you
- Copy your API key from Console → Settings; it is shown only after the code is verified, never sent by email
- Start making API requests with your key straight away. Free keys are accepted at 30 requests/day, 20 per hour; upgrade in Console → Billing for higher limits
First request
Critical threats from the last 7 days.
curl -G "https://radar.offseq.com/api/v1/threats" \ -H "X-API-Key: tr_your_api_key_here" \ -d "severity=critical" \ -d "days=7"
GET/api/v1/threats
The parameters most integrations use.
| Parameter | What it does | Example |
|---|---|---|
| cveId | One CVE in any casing, or the bare year-number form (alias of cve) | cveId=CVE-2025-1974 |
| days | Published in the last N days (1..3650) | days=7 |
| severity | low | medium | high | critical; comma-separated for several | severity=critical,high |
| type | Threat type, multi-value | type=vulnerability |
| search | Case-insensitive text across title, description, CVE, product, vendor, tags | search=nginx |
All 19 filters are in the .
POST/api/v1/match/batch
Match a host’s packages (purl, CPE or name + ecosystem) in one call.
curl "https://radar.offseq.com/api/v1/match/batch" \
-H "X-API-Key: tr_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"queries":[{"purl":"pkg:npm/lodash@4.17.20"},
{"package":{"name":"django","ecosystem":"PyPI"},"version":"4.2.0"}]}'One batch call counts as one request, whatever its size up to the cap. Per-batch cap by plan: Free 50 · Individual 200 · Base 200 · Pro 1,000 · Enterprise 4,000. Request and response shapes are in the .
Base URL
https://radar.offseq.com/api/v1Response format
JSON, Content-Type: application/json, on every endpoint.
OpenAPI 3.1 spec for every endpoint, no key needed: radar.offseq.com/openapi.json. Load it into Swagger UI, Postman or a client generator.
Features
- Real-time threat intelligence from multiple sources
- CVE vulnerability data with enriched metadata
- IoC (Indicators of Compromise) checking
- SBOM and lockfile scanning with SARIF and a CI gate (
POST /scan) - Versioned priority score on every threat and an explained patch order for a CVE list (
POST /prioritize) - Product, vendor, and version-level monitoring
- Geographic threat distribution data (structured + AI-identified)
- RESTful API with JSON responses
- API access on every tier, Free included, at 30 requests/day, 20 per hour
- Per-tier rate limiting, from Free through Enterprise
What you can filter on
GET /threats supports 19 filter parameters plus aliases. Highlights:
- Identifier normalization:
2025-1974=cve-2025-1974=CVE-2025-1974 - Fuzzy version match: finds
affectedVersions=1.24.0even in unstructured news/Reddit posts - Multi-value:
tag=a,borseverity=critical&severity=high - CVSS range:
minCvss=9.0 - Exploitation & patch flags:
hasExploits=true(exploited in the wild),publicExploit=true(public exploit code),hasPatch=true - EPSS movers:
epssRising=true,sort=epss_rising - Priority:
sort=priority,minPriority=critical - Date windows:
publishedAfter+publishedBeforeordays=7 - CWE filtering:
cwe=787 - Diagnostic headers: typos surface in
X-Unknown-Params
See the for the complete reference, or call GET /api/v1/threats/filters for a machine-readable catalog.
Authentication
API key required
All API requests require a valid API key. Every tier can call the API, including Free. Free keys are accepted at 30 requests/day (20/hour, 900/month). Individual, Base, Pro and Enterprise plans raise those quotas; see Rate limits.
Header authentication
Getting your API key
- Sign in at radar.offseq.com/console with your email address
- Enter the one-time code we email you
- Copy your API key from Console → Settings; it is shown only after the code is verified, never sent by email
- Your key works immediately at the Free tier (30 requests/day, 20 per hour); activate a plan in Console → Billing for higher limits
- Store your API key securely (treat it like a password)
Security best practices
- Never expose your API key in client-side code
- Store API keys in environment variables
- Regenerate keys if compromised (use the regenerate button in your account)
- Use HTTPS for all API requests
- Monitor your API usage regularly
Regenerating your API key
If your API key is compromised or you need a new one for any reason, you can regenerate it:
- Go to your account page
- After logging in, click the "Regenerate" button next to your API key
- Confirm the action (your old key will stop working immediately)
- Update your applications with the new API key
Warning: Regenerating your API key will immediately invalidate the old key. Make sure to update all your applications.
API endpoints
/threatsRetrieve a paginated list of threats with optional filtering.
Supports 19 filter parameters with aliases, identifier normalization (CVE/CWE), and fuzzy version matching. See the for the complete reference. Quick summary below.
Most-used parameters
| Parameter | Type | Example |
|---|---|---|
| search | string | nginx |
| severity | low | medium | high | critical (multi) | critical |
| type | string (multi) | vulnerability |
| cve | string (normalized) | CVE-2025-1974 |
| cwe | string (multi, normalized) | CWE-787 |
| product | string (substring, case-insensitive) | nginx |
| vendor | string (substring, case-insensitive) | F5 |
| affectedVersions | string (multi, fuzzy text fallback) | 1.24.0 |
| tag | string (multi, exact) | rce |
| country | string (multi, structured + AI-identified) | US |
| source | string (multi, case-insensitive exact) | NVD |
| hasExploits | boolean (exploited in the wild) | true |
| publicExploit | boolean (public exploit or PoC exists) | true |
| exploitMaturity | enum (multi) | WEAPONIZED |
| epssRising | boolean (EPSS up ≥ 0.05 in 7 days) | true |
| minPriority | 0..100 or critical | high | medium | low | high |
| hasPatch | boolean | true |
| minCvss / maxCvss | number 0..10 | 9.0 |
| publishedAfter / publishedBefore | ISO date | 2025-01-01 |
| days | integer 1..3650 | 7 |
| sort | field name (prefix with - for desc) | -publishedDate |
| page / limit | integer (limit capped per tier) | 1 / 50 |
Example request
curl -G "https://radar.offseq.com/api/v1/threats" \ -H "X-API-Key: tr_your_api_key_here" \ -d "severity=critical" \ -d "hasExploits=true" \ -d "minCvss=9" \ -d "days=7" \ -d "limit=20"
Response (200 OK)
{
"success": true,
"data": {
"threats": [
{
"_id": "6e1f2a...",
"slug": "cve-2025-1974-...",
"externalId": "CVE-2025-1974",
"source": "NVD",
"type": "vulnerability",
"title": "...",
"description": "...",
"cveId": "CVE-2025-1974",
"cvssScore": 9.8,
"severity": "critical",
"vendorProject": "...",
"product": "...",
"affectedVersions": ["=1.24.0", ">=1.25.0 <1.25.4"],
"cwes": ["CWE-787"],
"tags": ["rce", "rce-exploit"],
"affectedCountries": [],
"knownExploitsInWild": true,
"patchAvailable": true,
"patchLinks": ["https://..."],
"references": [{ "title": "...", "url": "...", "type": "advisory" }],
"publishedDate": "2025-...",
"enrichment": {
"enriched": true,
"aiGeneratedSummary": "...",
"aiGeneratedImpact": "...",
"aiGeneratedMitigation": "...",
"aiIdentifiedCountries": ["US", "DE"],
"confidence": 0.85
},
"reddit": { "postId": "...", "topComments": [ ... ] } // only on Reddit-sourced threats
}
],
"pagination": {
"page": 1, "limit": 20, "total": 142,
"pages": 8, "hasNext": true, "hasPrev": false
}
},
"meta": {
"timestamp": "2026-05-26T...",
"version": "v1",
"sort": { "publishedDate": -1 },
"unknownParams": [] // present only if you sent unknown params
}
}/threats/searchSearch-first variant of /threats. Requires q; accepts every filter the list endpoint supports. Use for autocomplete-style UIs where you want a smaller, flat response.
Query parameters
q is required. All other parameters from the Filtering tab also work.
Example
curl -G "https://radar.offseq.com/api/v1/threats/search" \ -H "X-API-Key: tr_your_api_key_here" \ -d "q=nginx" \ -d "severity=critical" \ -d "hasExploits=true"
/threats/slug/{slug}Look up a single threat by the URL slug used on the public site (e.g. poc-code-published-for-critical-nginx-vulnerabilit-3d78edaa). Returns 404 with a JSON error if no match.
curl "https://radar.offseq.com/api/v1/threats/slug/poc-code-published-for-critical-nginx-vulnerabilit-3d78edaa" \ -H "X-API-Key: tr_your_api_key_here"
/threats/{id}Look up a single threat by 24-character Mongo ObjectId. Non-ObjectId values return a 400 with a hint pointing to the slug endpoint.
For a CVE, this and /threats/slug/{slug} add consensus: { label, scorers, flags, sources }. label is agree, contested or thin; flags name each disagreement (score_spread, severity_vs_likelihood, disputed, thin) plus the informational exploited_outside_kev (exploited, not in CISA KEV), which never makes a CVE contested, and sources lists each scorer's CVSS (source, version, score, vector). It is computed per request and is not a list filter.
curl "https://radar.offseq.com/api/v1/threats/65d8f3a2c1e9b0a4d8f3a2c1" \ -H "X-API-Key: tr_your_api_key_here"
/threats/filtersself-describingMachine-readable catalog of every filter parameter, its aliases, and an example value. Use this in client code to discover supported filters at runtime instead of hard-coding.
curl "https://radar.offseq.com/api/v1/threats/filters" \
-H "X-API-Key: tr_your_api_key_here" | jq '.data.filters[] | {name, aliases, example}'/threats/feedsGET/threats/stats/overview/feeds: per-source threat counts, latest update timestamp, severity breakdown, and exploited count for each ingested feed. /stats/overview: total threats, 24h/7d/30d additions, severity distribution, top affected countries, top types.
curl "https://radar.offseq.com/api/v1/threats/feeds" \ -H "X-API-Key: tr_your_api_key_here" | jq '.data.feeds[] | select(.count > 100)'
/threats/check-iocsCheck if your Indicators of Compromise match known threats. Up to twice your plan’s results-per-request cap per call (10 to 100 indicators, 400 beyond); each indicator returns at most 5 threats.
Request body
{
"indicators": [
{
"type": "ip",
"value": "192.168.1.100"
},
{
"type": "domain",
"value": "malicious-site.com"
},
{
"type": "hash",
"value": "a1b2c3d4e5f6..."
}
]
}/threats/monitorMonitor threats affecting specific products or vendors.
Request body
{
"products": ["WordPress", "Apache", "nginx"],
"severities": ["high", "critical"],
"limit": 20
}/matchPrecise coordinate matching. Ask “is this exact package, at this exact version, affected?” bypurl,cpe, orpackage{name,ecosystem}. Version applicability is evaluated with each ecosystem’s native comparator (dpkg / rpm / apk / PEP 440 / semver), so Debian/Ubuntu/RHEL backport builds (e.g. 1.18.0-6+deb11u3) are not false-positived.
Request body (any one form)
{ "purl": "pkg:npm/lodash@4.17.20" }
// or distro packages — include the release so revisions compare correctly:
{ "purl": "pkg:deb/debian/nginx@1.18.0-6+deb11u3?distro=bullseye" }
// or name + ecosystem + version (version separate):
{ "package": { "name": "lodash", "ecosystem": "npm" }, "version": "4.17.20" }
// namespaced packages: the full OSV name (or a purl) matches exactly that package:
{ "package": { "name": "org.apache.logging.log4j:log4j-core", "ecosystem": "Maven" }, "version": "2.14.1" }
// (npm "@scope/name", Go module path, Packagist "vendor/name", GitHub Actions "owner/repo").
// A bare Maven or Packagist name ("log4j-core") matches that artifact in any group;
// such hits carry "nameOnly" (the package's full name) and the result a "note".
// An unscoped npm name, Go module path or Actions repo is its own package only.
// or by CPE 2.3:
{ "cpe": "cpe:2.3:a:apache:http_server:2.4.48:*:*:*:*:*:*:*" }Supply the version inside the purl OR in the version field, not both (400 otherwise). By default the response includes coordinate matches whose version couldn’t be confirmed (confirmed:false); add "strict": true to return only version-confirmed hits.
Example request
curl -X POST "https://radar.offseq.com/api/v1/match" \
-H "X-API-Key: tr_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"purl":"pkg:npm/lodash@4.17.20"}'Response (200 OK)
{
"success": true,
"data": {
"query": "pkg:npm/lodash",
"mode": "purl",
"ecosystem": "npm",
"version": "4.17.20",
"totalCandidates": 3,
"matches": [
{
"id": "...", "cveId": "CVE-2021-23337",
"title": "lodash command injection", "severity": "high",
"cvssScore": 7.2, "slug": "lodash-command-injection-ab12cd",
"kev": null, // or { addedDate, dueDate, ransomwareUse, requiredAction }
"epss": { "score": 0.4, "percentile": 0.97, "delta7d": 0.12 },
"exploit": { "maturity": "PUBLIC_EXPLOIT" }, // or null
"knownExploitsInWild": false,
"exploitation": { "euvd": false, "mirage": false }, // exploited per ENISA EUVD / OffSeq Mirage
"patchAvailable": true,
"fixedVersions": ["4.17.21"], // upgrade target(s), ecosystem-scoped
"remediation": "Upgrade to lodash 4.17.21 or later.",
"cwes": ["CWE-77"],
"references": ["https://nvd.nist.gov/vuln/detail/CVE-2021-23337"],
"matchBasis": "coordinate", // coordinate | cpe | coordinate-unconfirmed
"matchedRange": "<4.17.21",
"matchedCoordinate": "pkg:npm/lodash",
"confirmed": true // version confirmed inside an affected range
}
],
"upgradeTo": "4.17.21", // smallest version fixing every match, or null
"unfixed": [] // matches with no known fix above your version
},
"meta": { "version": "v1", "strict": false }
}/match/batchBulk coordinate matching (OSV /querybatch style). Send a whole host’s asset inventory in one request; results[] align positionally to queries[]. One batch call counts as one request, whatever its size up to the cap. Per-batch cap by plan: Free 50 · Individual 200 · Base 200 · Pro 1,000 · Enterprise 4,000.
Request body
{
"queries": [
{ "purl": "pkg:npm/lodash@4.17.20" },
{ "purl": "pkg:deb/ubuntu/openssl@1.1.1f-1ubuntu2.16?distro=focal" },
{ "package": { "name": "django", "ecosystem": "PyPI" }, "version": "4.2.0" },
{ "cpe": "cpe:2.3:a:apache:http_server:2.4.48:*:*:*:*:*:*:*" }
]
}Response (200 OK)
{
"success": true,
"data": {
"results": [
{ "query": "pkg:npm/lodash", "mode": "purl", "ecosystem": "npm",
"version": "4.17.20", "totalCandidates": 3, "matches": [ /* ...as above... */ ],
"upgradeTo": "4.17.21", "unfixed": [] },
{ "query": "pkg:deb/ubuntu/openssl", "mode": "purl", "matches": [] },
{ "query": "PyPI/django", "mode": "package", "matches": [ /* ... */ ] },
{ "query": "...", "mode": "cpe", "matches": [ /* ... */ ] }
]
},
"meta": { "version": "v1", "queries": 4, "queriesWithMatches": 2, "strict": false }
}Tip: GET /api/v1/match/help returns the live, self-describing contract (forms, rules, matchBasis meanings, tier caps).
/scanCI · SBOM · SARIFScan a whole SBOM or lockfile in one request. Radar reads the file, turns every package into a purl and runs it through the same version-aware matching as /match/batch. Findings come back riskiest first (CISA KEV, then risk score, known ransomware use, EPSS, exploit maturity, CVSS, the earlier KEV due date), with the smallest version to upgrade each package to.
Supported files
CycloneDX (JSON, XML) · SPDX (JSON, tag-value) · purl-per-line lists · package-lock.json · yarn.lock (v1, berry) · pnpm-lock.yaml · poetry.lock · uv.lock · Cargo.lock · go.mod / go.sum · composer.lock · Gemfile.lock · pinned requirements.txt. The format is read from format, then the file name, then the content.
Request
{ "content": "<the file's text>", "filename": "package-lock.json" }
// "format" (optional) skips detection: cyclonedx-json, spdx-json, package-lock,
// yarn-lock, pnpm-lock, poetry-lock, uv-lock, cargo-lock, go-mod, go-sum,
// composer-lock, gemfile-lock, requirements, purl-list, cyclonedx-xml, spdx-tag-value
// or POST a JSON SBOM/lockfile as-is, or the raw file as text/plain (?filename=yarn.lock)| Query parameter | What it does |
|---|---|
| format | json (default), sarif (SARIF 2.1.0 for code scanning), markdown (a GitHub step-summary table) or all (the JSON response plus top-level sarif and markdown, so CI gets all three from one request) |
| failOn | critical | high | medium | low | kev | exploited. Adds gate: { failOn, tripped } and an X-Radar-Gate: tripped | passed header. Only version-confirmed findings count; the status stays 200. |
| strict | true returns only version-confirmed findings |
| filename | Format hint for a raw text body |
Example request
jq -n --rawfile content package-lock.json '{content: $content, filename: "package-lock.json"}' |
curl -sS "https://radar.offseq.com/api/v1/scan?failOn=high" \
-H "X-API-Key: tr_your_api_key_here" \
-H "Content-Type: application/json" --data-binary @-Response (200 OK)
{
"success": true,
"data": {
"format": "package-lock",
"packages": 812, // unique packages read from the file
"findings": [ // riskiest first: KEV > risk score > ransomware > EPSS > exploit > CVSS > KEV due date
{ "purl": "pkg:npm/lodash@4.17.20", "ecosystem": "npm", "version": "4.17.20",
"cveId": "CVE-2021-23337", "title": "...", "severity": "high", "cvssScore": 7.2,
"epss": 0.4, "kev": false, "ransomware": false, "exploited": false, "exploitMaturity": null,
"riskScore": 47, "decision": "schedule", "confirmed": true,
"matchedRange": "<4.17.21", "fixedVersions": ["4.17.21"], "upgradeTo": "4.17.21",
"url": "https://radar.offseq.com/threat/..." }
],
"remediation": [
{ "purl": "pkg:npm/lodash@4.17.20", "version": "4.17.20", "findings": 2,
"upgradeTo": "4.17.21", "unfixed": [] }
],
"notChecked": [ { "purl": "pkg:pypi/flask", "reason": "no version" } ],
"summary": { "total": 3, "critical": 0, "high": 2, "medium": 1, "low": 0, "unknown": 0,
"kev": 0, "ransomware": 0, "exploited": 0, "packagesAffected": 2 },
"gate": { "failOn": "high", "tripped": true }
}
}SARIF (?format=sarif): one rule per vulnerability, one result per affected package. Level is error for KEV, CVSS ≥ 7 or an act-now / soon decision, otherwise warning or note;security-severity carries the CVSS score, tags mark kev and ransomware, and each rule links to its Radar page. One scan counts as one request. Packages per scan by plan: Free 50 · Individual 200 · Base 200 · Pro 1,000 · Enterprise 4,000; larger files return 413. Body limit 10 MB. Lines over 4,096 characters are rejected with 400 (JSON documents are exempt). For CI, use the .
/prioritizePatch orderRank 2 to 50 CVEs for patching and say why. CISA KEV listings come first, then the priority score (see the Filtering tab). Ties fall to known ransomware use, EPSS, exploit maturity, CVSS and the earlier KEV due date, then the order you gave. Each CVE is read from its canonical record (CVE Program, then GCVE/NVD, then CISA KEV). The same ranking powers the public compare page.
curl -sS https://radar.offseq.com/api/v1/prioritize \
-H "X-API-Key: tr_your_api_key_here" -H "Content-Type: application/json" \
-d '{"cveIds": ["CVE-2024-3400", "CVE-2023-4966", "CVE-2024-21887"]}'Response (200 OK)
{
"success": true,
"data": {
"ranked": [
{ "rank": 1, "cveId": "CVE-2024-3400", "title": "...", "url": "https://radar.offseq.com/threat/...",
"vendorProject": "Palo Alto Networks", "product": "PAN-OS", "cwes": ["CWE-77"],
"cvssScore": 10, "epss": { "score": 0.94, "percentile": 0.999, "delta7d": 0 },
"kev": { "addedDate": "2024-04-12T00:00:00.000Z", "dueDate": "2024-04-19T00:00:00.000Z", "ransomwareUse": "Unknown" },
"exploitMaturity": "WEAPONIZED", "exploited": { "kev": true, "euvd": true, "mirage": false },
"remoteUnauthenticated": true,
"priority": { "score": 100, "band": "critical", "version": "2026-10", "factors": [ ... ] },
"why": "Tied with CVE-2023-4966 at 100; EPSS 0.94 vs 0.92 breaks the tie" }
],
"patterns": [
{ "kind": "remoteUnauthenticated", "label": "Network attack, no privileges",
"cveIds": ["CVE-2024-3400", "CVE-2023-4966"], "note": "..." }
],
"notFound": [],
"version": "2026-10"
}
}patterns kinds: product, vendor and cwe (shared by two or more), and ransomware, exploitedOutsideKev (ENISA EUVD or OffSeq Mirage, not CISA KEV), remoteUnauthenticated (CVSS AV:N with PR:N) and epssRising (EPSS up ≥ 0.05 in 7 days). Ids are normalized and de-duplicated; anything that is not a CVE id returns 400. One call counts as one request.
/inventory/registerContinuous monitoringRegister a host’s scanned coordinates (with network exposure) so Radar keeps watching: when a newly-ingested threat affects one of them, the account is alerted (email + realtime + webhook). The response returns the host’s current exposure-aware, risk-scored findings, inventory drift, and what’s new since the last scan. This is the two-way handshake threat-finder uses after a scan (it asks [Y/n/never]).
Request body
{
"hostId": "<stable uuid>",
"hostname": "web-01",
"os": { "type": "linux", "distro": "ubuntu", "version": "22.04" },
"agentVersion": "0.1.4",
"monitor": true,
"assets": [
{ "purl": "pkg:deb/ubuntu/openssl@1.1.1f-1ubuntu2.16?distro=jammy",
"exposure": "public", // public | private | loopback | none
"exposed": true, "runtime": true }
]
}Response (200 OK)
{
"success": true,
"data": {
"hostId": "...", "monitoring": true, "new": true, "assetCount": 1840,
"drift": { "added": 5, "removed": 2, "changed": 3 },
"summary": { "total": 12, "confirmed": 9, "kev": 2, "exposed": 3,
"bySeverity": { "critical": 1, "high": 4, "medium": 5, "low": 2 },
"byDecision": { "act-now": 2, "soon": 3, "schedule": 4, "track": 3 }, "actNow": 2 },
"top": [
{ "cveId": "CVE-2021-3711", "severity": "critical", "exposure": "public",
"exposed": true, "confirmed": true, "riskScore": 92, "decision": "act-now",
"coordinate": "pkg:deb/ubuntu/openssl", "fixedVersions": ["1.1.1f-1ubuntu2.17"],
"remediation": "Upgrade openssl…", "radarUrl": "https://radar.offseq.com/threat/..." }
],
"newSinceLast": [ /* findings new vs the previous scan of this host */ ],
"newSinceLastCount": 1,
"resolved": [
{ "cveId": "CVE-2023-0286", "assetKey": "ubuntu:jammy|openssl", "reason": "upgraded" }
],
"resolvedCount": 1
}
}GET /inventory: list your monitored hosts.GET /inventory/{hostId}: host detail + current findings, and resolved since the last scan.PATCH /inventory/{hostId}: toggle monitoring / rename.DELETE /inventory/{hostId}: deregister a host.Host and scan findings use a risk score (0–100), not the threat’s priority score: it adds network exposure to the same EPSS, KEV and exploit points. Risk score & decision fuse severity (critical 40, high 30, medium 18, low 8) + EPSS + KEV + exploit maturity + exploited, not in CISA KEV (ENISA EUVD, OffSeq Mirage) + network exposure (public 12, private 7; scans have none), ×0.6 when the version is unconfirmed, into an explainableact-now / soon / schedule / track band, the SSVC input most tools lack because they can’t see what’s actually running and exposed. Per-tier host caps: Free 2 · Individual 3 · Base 10 · Pro 50 · Enterprise 200.
resolved lists findings from the previous scan that are gone, with a reason:removed (package no longer installed),upgraded (new version no longer affected) orno_longer_affected (same version, the advisory changed). A finding Radar could not re-check is left out rather than reported as resolved. Monitored hosts are also re-alerted, once per change, when an already-reported finding gets riskier: added to CISA KEV, exploited per ENISA EUVD, a Metasploit module or public exploit, or an EPSS jump; webhook host_finding payloads then carry escalated: true and per-finding reasons.
/decisionsDecision ledgerRecord what you decided about a CVE (patch_now, defer,accept, not_affected, mitigated), account-wide or on one host or asset, with the assumptions it rests on. Radar re-checks them whenever the CVE’s signals change and once a day. When one breaks the decision turns stale with the exact reason, a passed until makes it expired, and a manual assumption past its review date makes it review_due. You are told once, in the bell, through thedecision_stale webhook and Slack event and by email when alerts are on. While active, accept, not_affected, mitigated and defer (until its date) decisions silence new-finding and escalation alerts for that CVE (or that host); stale and expired ones do not. One decision per CVE and scope: a second POST for the same CVE and scope returns 409 with the existing id, so PATCH that one instead. An account keeps at most 5,000 decisions.
Request body
{
"cveId": "CVE-2026-12345",
"scope": "cve", // cve (account-wide) | host (with hostId, optional assetKey)
"verdict": "defer",
"until": "2026-12-01", // required for defer
"justification": null, // not_affected / mitigated only, one of: component_not_present,
// vulnerable_code_not_present, vulnerable_code_not_in_execute_path,
// vulnerable_code_cannot_be_controlled_by_adversary, inline_mitigations_already_exist
"note": "Patch in the December window",
"assumptions": [
{ "type": "not_in_kev" },
{ "type": "epss_below", "threshold": 0.10 },
{ "type": "no_public_exploit" },
{ "type": "manual", "text": "Admin UI only on the VPN", "reviewBy": "2027-01-15" }
]
}A decision that went stale
{
"id": "6720…", "cveId": "CVE-2026-12345", "verdict": "defer", "status": "stale",
"staleReasons": ["entered CISA KEV on 2026-10-27", "EPSS 0.03 → 0.41 (threshold 0.10)"],
"suppressing": false,
"snapshot": { "kev": false, "epss": 0.03, "maturity": null, "cvss": 7.5 }
}GET /decisions: your decisions, newest first; filter by status, verdict, cveId, hostId. page 1–1000, limit 1–200 (default 50).GET /decisions/suggestions?cveId=: the assumptions that hold now, with thresholds one step above today’s EPSS and CVSS.PATCH /decisions/{id}: edit; changing verdict, until or assumptions re-records it.POST /decisions/{id}/confirm: re-confirm after review (fresh snapshot, back to active).DELETE /decisions/{id}: delete.GET /decisions/calibration?days=30|90|365: how many deferrals and accepted risks later went stale, why, and the median days to stale.GET /decisions/vex?format=openvex|cyclonedx: every decision as OpenVEX 0.2.0 or CycloneDX 1.5 VEX.GET /inventory/{hostId}/vex?format=: one host’s decisions, naming affected assets by purl; each CVE and product appears once, the most specific decision winning (asset > host > account); a patch_now CVE the host no longer has is reported fixed.Assumption types: not_in_kev, epss_below (0–1),no_public_exploit, not_weaponized,not_exploited (CISA KEV, ENISA EUVD, OffSeq Mirage), cvss_below (0–10) and manual (review after 90 days unless you set reviewBy). They must hold when you record or re-confirm. In VEX, not_affected and mitigated export as not_affected with their justification, patch_now, defer and accept asaffected with an action statement (CycloneDX exploitable with response update or will_not_fix), and stale or expired decisions as under_investigation (in_triage) with the reason. Host findings from GET /inventory/{hostId} carry assetKey and recordedDecision.
Filtering reference
Every parameter the API accepts on GET /threats and GET /threats/search. Parameters combine with AND. Where indicated, a parameter accepts multiple values via comma (?tag=a,b) or repetition (?tag=a&tag=b).
Response headers help you debug filters
X-Unknown-Params: comma-separated list of parameters the API didn't recognize (typos surface here instead of silently returning the whole DB).X-Warnings: soft warnings (e.g. invalid sort field, malformed date) that didn't fail the request.X-Limit-Capped: set when your requestedlimitexceeded your tier cap (e.g.1000->100on Pro).
Complete parameter reference
| Parameter | Aliases | Type | Behavior | Example |
|---|---|---|---|---|
| search | q | string | Case-insensitive substring across title, description, cveId, product, vendorProject, tags, cwes, affectedVersions, AI summary, Reddit selftext. | nginx |
| type | types | string (multi) | vulnerability, exploit, malware, phishing, botnet, campaign, threat-actor, breach, indicator, security-tool, analysis, security-news. | vulnerability |
| severity | severities | enum (multi) | low | medium | high | critical. Invalid values silently dropped. | critical,high |
| cve | cveId | string (normalized) | "2025-1974", "cve-2025-1974", "CVE-2025-1974" all match the same record. Non-canonical input falls back to regex. | CVE-2025-1974 |
| cwe | cwes | string (multi, normalized) | "787" or "CWE-787" both resolve to CWE-787. | CWE-787,CWE-22 |
| product | — | string | Case-insensitive substring on structured Threat.product. Regex-safe. | nginx |
| vendor | vendorProject, vendors | string | Case-insensitive substring on structured Threat.vendorProject. | F5 |
| affectedVersions | version, versions, affectedVersion | string (multi) | Structured + fuzzy text match. Matches Threat.affectedVersions[] AND (by default) word-boundary regex of the version string in title, description, AI summary, Reddit selftext. Disable text fallback with ?fuzzy=false. | 1.24.0 |
| purl | — | string (multi) | Coordinate filter. Narrows to threats affecting this Package URL (indexed exact match on ecosystem+name). Does NOT compare versions; for version-aware applicability use POST /match. | pkg:npm/lodash |
| cpe | — | string (multi) | Coordinate filter by CPE 2.3 vendor/product. For version-range applicability use POST /match. | cpe:2.3:a:apache:http_server:*:*:*:*:*:*:*:* |
| ecosystem | + product / packageName | string | OSV-style ecosystem (npm, PyPI, Go, Maven, Debian:11, …). Combine with product for a coordinate match. | ecosystem=npm&product=lodash |
| country | countries | string (multi) | Case-insensitive exact match against affectedCountries[] OR enrichment.aiIdentifiedCountries[]. | US |
| source | sources | string (multi) | Case-insensitive exact source name. Use /threats/feeds to enumerate available sources. | NVD |
| tag | tags | string (multi) | Case-insensitive exact match against tags[] element. | rce |
| hasExploits | exploited | boolean | true / false / 1 / 0 / yes / no. Matches knownExploitsInWild (exploited in the wild: CISA KEV and similar reports), not the existence of public exploit code; for that use publicExploit. | true |
| publicExploit | — | boolean | true: public exploit code or a proof of concept exists (any exploit.maturity). false: none known. | true |
| exploitMaturity | — | enum (multi) | Exact levels, comma-separated: WEAPONIZED | PUBLIC_EXPLOIT_REMOTE | PUBLIC_EXPLOIT | POC_HIGH | POC_LOW (see Exploit maturity below). Takes precedence over publicExploit. | WEAPONIZED,PUBLIC_EXPLOIT_REMOTE |
| epssRising | — | boolean | EPSS score rose by at least 0.05 over the last 7 days (epss.delta7d ≥ 0.05). Tracked for CVEs in CISA KEV or with EPSS ≥ 0.10 or percentile ≥ 0.90. | true |
| minPriority | — | integer 0..100 or band | Stored priority score at least this value, or at least a band floor: critical 76, high 50, medium 25 (see Spotlight: priority score below). | 76 |
| hasPatch | patched | boolean | true / false. Matches patchAvailable. | true |
| euvdExploited | — | boolean | Listed as exploited in ENISA's EU Vulnerability Database (synced every 6 hours). Matches euvd.exploited. | true |
| exploitedNotInKev | — | boolean | Exploited per ENISA EUVD or OffSeq Mirage sensors, but not (yet) in CISA KEV. | true |
| minCvss | — | number 0..10 | CVSS score ≥ N. | 9.0 |
| maxCvss | — | number 0..10 | CVSS score ≤ N. | 10 |
| publishedAfter | startDate, from | ISO 8601 date | publishedDate ≥ date. | 2025-01-01 |
| publishedBefore | endDate, to | ISO 8601 date | publishedDate ≤ date. | 2025-12-31 |
| days | — | integer 1..3650 | Threats ingested within the last N days (createdAt). | 7 |
| fuzzy | — | boolean | Only affects affectedVersions. Set to false to require a structured match (skip the text fallback). | false |
| page | — | integer ≥ 1 | Pagination. Default 1. | 2 |
| limit | — | integer | Items per page. Default 10. Capped per tier (Free 10 · Individual 25 · Base 50 · Pro 100 · Enterprise 100). | 50 |
| sort | — | string | Allowed fields: publishedDate, createdAt, cvssScore, severity, voteScore, viewCount, title, epssScore, epssPercentile, epss_rising, priority. epss_rising lists tracked CVEs by their 7-day EPSS rise, largest first; priority lists by priority score, highest first. Prefix with - for descending. Multiple fields comma-separated. Default: -publishedDate. | -cvssScore |
| order | — | asc | desc | Used with sort when you don't want to prefix with -. | desc |
Don't remember which parameter takes which alias? Hit GET /api/v1/threats/filters to fetch the live catalog.
Spotlight: fuzzy version matching
Many threats (especially news articles, Reddit posts, and write-ups) describe affected versions in prose without populating the structured affectedVersions[] field. By default affectedVersions=1.24.0 matches:
- Threats whose structured
affectedVersions[]array contains the value, OR - The version string appears in
title,description,enrichment.aiGeneratedSummary,enrichment.aiGeneratedImpact, orreddit.selftextText
Word-boundary regex anchored on non-digit/non-dot ensures 1.24.0 doesn't accidentally match 11.24.05.
Stored values use a single canonical comparator grammar so ranges are machine-comparable: =1.24.0 (exact), <2.4.49 (fixed in 2.4.49), >=2.0.0 <2.5.2 (interval, upper bound exclusive), * (all versions). Operators are >= > <= < =; disjoint ranges are separate array entries. Lower bounds are inclusive, "fixed in X" is the exclusive upper bound <X, matching the OSV / CVE 5.x / semver convention. A query for affectedVersions=1.24.0 still matches the stored pin =1.24.0.
Need strict structured-only? Add &fuzzy=false.
Spotlight: coordinate matching (purl / CPE)
Free-text name search is noisy (name collisions) and lossy at scale. When you know a package’s exact coordinate, match by it instead:
- Filter the list endpoint with
?purl=/?cpe=/?ecosystem=&product=(indexed exact coordinate, no version compare). - Match a concrete version with
POST /match/POST /match/batch; see the Endpoints tab.
Threats now carry two structured coordinate fields (returned in every threat object):
"affectedPackages": [
{ "ecosystem": "npm", "purlType": "npm", "name": "lodash",
"purl": "pkg:npm/lodash", "ranges": ["<4.17.21"], "versions": [], "source": "cve" }
],
"cpes": [
{ "cpe23": "cpe:2.3:a:apache:http_server:*:*:*:*:*:*:*:*",
"vendor": "apache", "product": "http_server", "versionEndExcluding": "2.4.49" }
]Distro-backport correctness: for pkg:deb / pkg:rpm / pkg:apk packages, POST /match compares versions with the distro’s own algorithm (dpkg / rpm), keeping the full revision (1.18.0-6+deb11u3). A build that backported the fix is correctly reported not affected. Pass the ?distro= qualifier so the right release is used.
affectedVersions[] remains a canonical, ecosystem-agnostic superset of every package’s ranges, so the simple version filter keeps working everywhere.
Spotlight: exploit maturity and EPSS movement
Every threat object carries exploit when public exploit evidence exists for its CVE, refreshed daily from Metasploit, Exploit-DB, nuclei-templates and PoC-in-GitHub. maturity is the strongest level seen and never goes down when a source is removed:
WEAPONIZED: a Metasploit exploit modulePUBLIC_EXPLOIT_REMOTE: an Exploit-DB remote or web-application exploitPUBLIC_EXPLOIT: any other Exploit-DB exploitPOC_HIGH: a nuclei detection templatePOC_LOW: a proof-of-concept repository on GitHub
epss adds previousScore / previousPercentile (the reading before the latest change) and peakScore / peakAt. For CVEs in CISA KEV or with EPSS ≥ 0.10 or percentile ≥ 0.90 it also has delta7d (score now minus ~7 days ago) and history, up to 30 daily points.
"exploit": {
"maturity": "WEAPONIZED",
"sources": [
{ "type": "metasploit", "ref": "exploit/linux/http/panos_management_unauth_rce", "url": "https://github.com/rapid7/..." },
{ "type": "nuclei", "ref": "http/cves/2024/CVE-2024-0012.yaml", "url": "https://github.com/projectdiscovery/..." }
],
"firstSeenAt": "2026-10-29T00:15:00.000Z", "updatedAt": "2026-10-29T00:15:00.000Z"
},
"epss": {
"score": 0.94, "percentile": 0.999, "previousScore": 0.41, "previousPercentile": 0.97,
"delta7d": 0.53, "peakScore": 0.94, "peakAt": "2026-10-28T00:00:00.000Z",
"history": [{ "d": "2026-10-21", "s": 0.41, "p": 0.97 }, { "d": "2026-10-28", "s": 0.94, "p": 0.999 }]
}Spotlight: priority score
Every threat object in list and detail responses carries priority: how urgently its CVE needs patching anywhere, before Radar knows your hosts. Host and scan findings carry a separate riskScore (0–100) that adds network exposure and shares the epss, kev, kevOverdue, exploit and exploited points below, see /inventory/register. The factor points always add up to the score.
| Factor | Points (version 2026-10) |
|---|---|
| cvss | CVSS × 3 (0–30); without a score, the severity band floor (critical 27, high 21, medium 12, low 0) |
| epss | EPSS × 25 (0–25) |
| kev | 20 when listed in CISA KEV |
| ransomware | 5 when CISA KEV records known ransomware use |
| kevOverdue | 5 once the KEV due date has passed |
| exploit | WEAPONIZED 10, PUBLIC_EXPLOIT_REMOTE / PUBLIC_EXPLOIT 7, POC_HIGH / POC_LOW 3 |
| exploited | 15 when ENISA EUVD or OffSeq Mirage reports exploitation and CISA KEV does not |
| booster | ×1.15 when exploited (KEV, EUVD or Mirage) with a public exploit; ×1.10 when CVSS ≥ 9 and EPSS ≥ 0.7; capped at 100 |
| kevFloor | Tops a CISA KEV listing up to 76, so every KEV CVE is critical |
| exploitedFloor | Tops exploitation reported only by ENISA EUVD or OffSeq Mirage up to 50, so it is at least high |
Bands: critical ≥ 76, high ≥ 50, medium ≥ 25, low below. Without exploitation evidence the ceiling is 72, so critical always means exploited.sort=priority and minPriority use the stored priorityScore, refreshed by the KEV, EPSS, EUVD, Mirage and exploit syncs; priority.score is computed on read and can run ahead of it by the overdue points until the next daily KEV sync.
Versioning. priority.version names the formula. Any change to a weight, booster, floor or band boundary ships as a new version, announced here before it goes live, so thresholds you pin (for example minPriority=76 in an alert) keep their meaning until you move them. New data (a KEV listing, an EPSS change) moves scores within a version; the formula does not.
"priority": {
"score": 100, "band": "critical", "version": "2026-10",
"factors": [
{ "factor": "cvss", "points": 29, "detail": "CVSS 9.8" },
{ "factor": "epss", "points": 24, "detail": "EPSS 0.94 (99th percentile)" },
{ "factor": "kev", "points": 20, "detail": "In CISA KEV since 2024-04-12" },
{ "factor": "exploit", "points": 10, "detail": "Weaponized exploit" },
{ "factor": "booster", "points": 17, "detail": "×1.15 exploited with a public exploit; ×1.10 CVSS ≥ 9 and EPSS ≥ 0.7" }
]
}Cookbook: common patterns
CISA-KEV-style watchlist (critical CVEs being exploited in the wild)
GET /api/v1/threats?severity=critical&hasExploits=true&hasPatch=true&days=30&sort=-cvssScore&limit=50
Find anything affecting nginx 1.24.0 (fuzzy text match included)
GET /api/v1/threats?product=nginx&affectedVersions=1.24.0&limit=50
Monitor my stack: multi-vendor with severity floor
GET /api/v1/threats?vendor=F5&severity=high,critical&days=14
Memory-corruption (CWE-787) RCE class, last quarter
GET /api/v1/threats?cwe=787&tag=rce&publishedAfter=2025-01-01&publishedBefore=2025-03-31
Specific CVE by any input form
GET /api/v1/threats?cve=2025-1974 GET /api/v1/threats?cve=cve-2025-1974 GET /api/v1/threats?cve=CVE-2025-1974
All three return the same record.
Page through all critical threats in chronological order
GET /api/v1/threats?severity=critical&sort=publishedDate&order=asc&page=1&limit=50
Find threats touching the US (structured or AI-identified)
GET /api/v1/threats?country=US&severity=critical&days=30
Error responses
| Status | When | Body |
|---|---|---|
| 400 | Bad request, e.g. /threats/{id} with a non-ObjectId, or missing q on /search. | {"success":false,"error":"...","message":"..."} |
| 401 | Missing or invalid API key. | {"error":"API key required"} |
| 403 | Profile incomplete, or the feature needs a paid plan. | {"error":"API access required"} |
| 404 | No matching threat (single-document endpoints). | {"success":false,"error":"Threat not found"} |
| 429 | Rate limit exceeded (hourly/daily/monthly). | See the Rate Limits tab; includes retry-after header. |
| 500 | Internal error. The response body's message field includes a short description. | {"success":false,"error":"...","message":"..."} |
Why does my unknown parameter not return an error?
For backward-compatibility, the API never 400s on unknown parameters. It accepts the request, ignores the typo, and surfaces it via the X-Unknown-Params response header and the meta.unknownParams JSON field. Inspect those during integration to catch typos early. If you'd like a strict-mode flag that 400s instead, contact support.
Code examples
cURL
Get latest critical threats
curl -X GET "https://radar.offseq.com/api/v1/threats?severity=critical&limit=10" \ -H "X-API-Key: tr_your_api_key_here"
Search for CVE
curl -X GET "https://radar.offseq.com/api/v1/threats/search?q=CVE-2024-0001" \ -H "X-API-Key: tr_your_api_key_here"
Check IoCs
curl -X POST "https://radar.offseq.com/api/v1/threats/check-iocs" \
-H "X-API-Key: tr_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"indicators": [
{"type": "ip", "value": "192.168.1.100"},
{"type": "domain", "value": "suspicious-site.com"}
]
}'Filter by affected version (fuzzy text fallback)
Matches structured affectedVersions[] AND any text mention of 1.24.0 in title, description, AI summary, or Reddit selftext.
curl -G "https://radar.offseq.com/api/v1/threats" \ -H "X-API-Key: tr_your_api_key_here" \ -d "product=nginx" \ -d "affectedVersions=1.24.0" \ -d "limit=50"
CWE class + RCE tag + date range
curl -G "https://radar.offseq.com/api/v1/threats" \ -H "X-API-Key: tr_your_api_key_here" \ -d "cwe=787" \ -d "tag=rce" \ -d "publishedAfter=2025-01-01" \ -d "publishedBefore=2025-03-31" \ -d "sort=-cvssScore"
CISA-KEV-style: exploited critical with patches, last 30 days
curl -G "https://radar.offseq.com/api/v1/threats" \ -H "X-API-Key: tr_your_api_key_here" \ -d "severity=critical" \ -d "hasExploits=true" \ -d "hasPatch=true" \ -d "days=30" \ -d "minCvss=9.0" \ -d "sort=-publishedDate"
Discover the filter catalog at runtime
curl -X GET "https://radar.offseq.com/api/v1/threats/filters" \
-H "X-API-Key: tr_your_api_key_here" \
| jq '.data.filters[] | "\(.name) (\(.aliases // [] | join(",")))"'Inspect diagnostic headers (catch typos)
-D - dumps response headers. Watch for X-Unknown-Params and X-Warnings.
curl -G -D - "https://radar.offseq.com/api/v1/threats" \ -H "X-API-Key: tr_your_api_key_here" \ -d "severity=critical" \ -d "afected_versions=1.24.0" \ -o /dev/null \ -s | grep -i "^x-" # X-Unknown-Params: afected_versions # X-RateLimit-Remaining-Hourly: 374
Python
Basic setup
import requests
import json
API_KEY = "tr_your_api_key_here"
BASE_URL = "https://radar.offseq.com/api/v1"
HEADERS = {"X-API-Key": API_KEY}
def get_threats(severity="critical", limit=10):
url = f"{BASE_URL}/threats"
params = {"severity": severity, "limit": limit}
response = requests.get(url, headers=HEADERS, params=params)
response.raise_for_status()
return response.json()
# Get critical threats
threats = get_threats()
print(f"Found {len(threats['data']['threats'])} critical threats")Monitor your infrastructure
def monitor_products(products, severities=["high", "critical"]):
url = f"{BASE_URL}/threats/monitor"
data = {
"products": products,
"severities": severities,
"limit": 50
}
response = requests.post(url, headers=HEADERS, json=data)
response.raise_for_status()
return response.json()
# Monitor your tech stack
my_products = ["WordPress", "Apache", "MySQL", "Ubuntu"]
threats = monitor_products(my_products)
for threat in threats['data']['threats']:
print(f" {threat['severity'].upper()}: {threat['title']}")
if threat.get('cveId'):
print(f" CVE: {threat['cveId']}")
print(f" Product: {threat.get('product', 'N/A')}")
print()Poll for new threats
Run on a schedule. Asks only for records ingested since the last run (newest first) and stops at the first one already seen, so a quiet hour costs one request. Set limit to your plan’s page cap (Free 10 · Individual 25 · Base 50 · Pro 100 · Enterprise 100).
import json, pathlib
CURSOR = pathlib.Path("radar_cursor.json")
def poll_new_threats(**filters):
"""Yield threats ingested since the stored cursor, newest first."""
since = json.loads(CURSOR.read_text())["createdAt"] if CURSOR.exists() else None
newest, page = since, 1
while True:
r = requests.get(
f"{BASE_URL}/threats",
headers=HEADERS,
params={**filters, "days": 2, "sort": "-createdAt", "limit": 50, "page": page},
)
r.raise_for_status()
body = r.json()["data"]
for threat in body["threats"]:
if since and threat["createdAt"] <= since:
CURSOR.write_text(json.dumps({"createdAt": newest}))
return
newest = max(newest or "", threat["createdAt"])
yield threat
if not body["pagination"]["hasNext"]:
break
page += 1
if newest:
CURSOR.write_text(json.dumps({"createdAt": newest}))
# Run hourly: new critical threats only
for t in poll_new_threats(severity="critical"):
print(t["cveId"], t["title"][:80])Lookup by URL slug
When you have a radar.offseq.com URL and need the underlying threat record.
from urllib.parse import urlparse
def fetch_by_url(threat_url):
slug = urlparse(threat_url).path.rsplit("/", 1)[-1]
r = requests.get(f"{BASE_URL}/threats/slug/{slug}", headers=HEADERS)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()["data"]
threat = fetch_by_url("https://radar.offseq.com/threat/poc-code-published-for-critical-nginx-vulnerabilit-3d78edaa")
print(threat["title"], threat.get("cvssScore"))JavaScript / Node.js
Basic client + advanced filters
const axios = require('axios');
const api = axios.create({
baseURL: 'https://radar.offseq.com/api/v1',
headers: { 'X-API-Key': process.env.RADAR_API_KEY }
});
// Composable filter helper — every filter from the Filtering tab works
async function searchThreats(filters) {
const { data, headers } = await api.get('/threats', { params: filters });
// Catch typos early — unknown params surface here
if (headers['x-unknown-params']) {
console.warn('Unknown params (ignored):', headers['x-unknown-params']);
}
if (headers['x-warnings']) {
console.warn('Warnings:', headers['x-warnings']);
}
if (headers['x-limit-capped']) {
console.warn('Limit capped:', headers['x-limit-capped']);
}
return data.data;
}
// CISA-KEV-style: exploited critical with patches, last 30 days
const kev = await searchThreats({
severity: 'critical',
hasExploits: 'true',
hasPatch: 'true',
minCvss: 9.0,
days: 30,
sort: '-cvssScore',
limit: 50
});
console.log(`${kev.pagination.total} matching threats`);
kev.threats.forEach(t => console.log(t.cveId, t.title));Lookup by slug + IoC matching
// Get a single threat by URL slug
async function fetchBySlug(slug) {
try {
const { data } = await api.get(`/threats/slug/${slug}`);
return data.data;
} catch (e) {
if (e.response?.status === 404) return null;
throw e;
}
}
// Check observed IoCs against the threat corpus
async function checkIoCs(indicators) {
const { data } = await api.post('/threats/check-iocs', { indicators });
return data.data.results;
}
// Usage
const threat = await fetchBySlug('poc-code-published-for-critical-nginx-vulnerabilit-3d78edaa');
if (threat) console.log(threat.title);
const matches = await checkIoCs([
{ type: 'ip', value: '192.168.1.100' },
{ type: 'domain', value: 'suspicious-site.com' }
]);Discover filters at runtime (useful for SDK auto-generation)
const { data } = await api.get('/threats/filters');
console.table(data.data.filters.map(f => ({
name: f.name,
aliases: (f.aliases || []).join(', '),
type: f.type,
example: f.example
})));Rate limits
API access is included on every plan, Free too. Each HTTP request counts once against the hour, day and month windows; a monthly quota is 30 days of the daily one, so the daily quota is the one to plan around. One /match/batch call counts as one request, whatever its size up to the batch cap.
| Tier | Per hour | Per day | Per month | Page size | Batch | Notes |
|---|---|---|---|---|---|---|
| Free | 20 | 30 | 900 | 10 | 50 | Any registered key. Upgrade for alerts and higher limits. |
| Individual | 25 | 75 | 2,250 | 25 | 200 | One person, personal or freelance use. |
| Individual Lifetime | 25 | 75 | 2,250 | 25 | 200 | One-time licence with Individual limits. |
| Base | 75 | 300 | 9,000 | 50 | 200 | Small team, one integration, commercial licence. |
| Pro | 375 | 1,500 | 45,000 | 100 | 1,000 | 5× Base usage. |
| Enterprise | 1,500 | 6,000 | 180,000 | 100 | 4,000 | 20× Base usage, priority support. |
| Lifetime (legacy) | 45 | 150 | 4,500 | 25 | 200 | Legacy Lifetime licence, no longer sold. |
Which plan fits: one poll is one request, so the daily quota sets how often you can poll. Individual polls every 20 minutes, Base every 5 minutes, Pro every minute and Enterprise every 15 seconds.
Poll only new items
Ask for the newest records first and stop at the first one you have already seen. Keep the newest createdAt as your cursor; a quiet interval then costs one request. Set limit to your plan’s page size (Pro shown); the Examples tab has a full Python version.
curl -G "https://radar.offseq.com/api/v1/threats" -H "X-API-Key: $RADAR_API_KEY" -d "sort=-createdAt" -d "days=1" -d "limit=100" # Walk data.threats in order; stop at the first createdAt <= your cursor # (or the first _id you already stored). Fetch page 2 only if every row was new.
Rate limit headers (Pro example)
API responses include rate limit information in the headers:
X-RateLimit-Limit-Hourly: 375 X-RateLimit-Remaining-Hourly: 370 X-RateLimit-Reset-Hourly: 1640995200 X-RateLimit-Limit-Daily: 1500 X-RateLimit-Remaining-Daily: 1480 X-RateLimit-Reset-Daily: 1641024000 X-RateLimit-Limit-Monthly: 45000 X-RateLimit-Remaining-Monthly: 44960
Rate limit exceeded
When you exceed your rate limit, you'll receive a 429 status code with a Retry-After header, a Link: <…/pricing>; rel="payment" header, and an upgrade object naming the plan that lifts the limit:
{
"error": "Monthly rate limit exceeded",
"message": "You have exceeded the monthly limit of 45000 requests. Upgrade for higher limits at https://radar.offseq.com/console?tab=billing (compare plans: https://radar.offseq.com/pricing).",
"rateLimits": {
"requestsPerHour": 375,
"requestsPerDay": 1500,
"requestsPerMonth": 45000
},
"upgradeUrl": "https://radar.offseq.com/console?tab=billing",
"pricingUrl": "https://radar.offseq.com/pricing",
"upgrade": {
"reason": "api-month",
"plan": "pro",
"next": {
"id": "enterprise",
"name": "Enterprise",
"limits": {
"requestsPerHour": 1500,
"requestsPerDay": 6000,
"requestsPerMonth": 180000,
"maxResultsPerRequest": 100,
"aiRefreshPerDay": 400
},
"price": {
"currency": "EUR",
"month": 490,
"year": 4900
}
},
"retryAfter": 432000,
"urls": {
"signup": "https://radar.offseq.com/console?auth=signup",
"pricing": "https://radar.offseq.com/pricing",
"checkout": "https://radar.offseq.com/checkout?plan=enterprise&interval=month",
"contact": "https://radar.offseq.com/contact",
"docs": "https://radar.offseq.com/api-docs"
}
},
"docs": "https://radar.offseq.com/api-docs"
}Need more than Enterprise? Enterprise Custom adds more hosts, bulk export and an SLA, priced per contract.
Contact salesSDKs & integration tools
Integrate Threat Radar into your security stack with these tools and examples.
The official open-source host scanner. It enumerates a machine’s running services, resolves each to an exact package coordinate, and batches them through POST /api/v1/match/batch, so version applicability (including Linux distro backports) is decided server-side. The fastest way to go from an API key to “what on my box is vulnerable.”
Install (Linux & macOS)
# Homebrew (macOS/Linux): prebuilt, no toolchain brew install offseq/tap/threat-finder # or a prebuilt binary via cargo-binstall cargo binstall threat-finder # or build from crates.io cargo install threat-finder
Prefer a tarball? Grab a prebuilt release (x86_64 / arm64). Windows isn’t supported.
Run & gate CI
# scan this host (key via env var) export OFFSEQ_API_KEY=tr_your_api_key_here threat-finder # CI: no prompts, JSON + SARIF, fail on exposed exploited CVEs OFFSEQ_API_KEY=$KEY threat-finder --yes \ --json --output report.json --sarif results.sarif \ --fail-on exposed
Why it’s accurate: it sends package coordinates (purl/CPE), not just names, to the , so the server compares versions with each ecosystem’s native algorithm (dpkg / rpm / apk / PEP 440 / semver). See the for how purl/CPE applicability works.
GitHub Action
SBOM & lockfile scanningScans an SBOM or lockfile on every push or pull request through : writes the findings table to the job summary, uploads SARIF to Security → Code scanning, and fails the job when fail-on matches a version-confirmed finding. Inputs: api-key, file, fail-on (critical | high | medium | low | kev | exploited), upload-sarif (default true).
permissions:
contents: read
security-events: write
jobs:
radar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: offseq/radar-scan-action@v1
with:
api-key: ${{ secrets.RADAR_API_KEY }}
file: package-lock.json
fail-on: highA run costs one API request: it asks for format=all and builds the summary and SARIF from that one response. Packages per scan follow your plan’s batch cap: Free 50 · Individual 200 · Base 200 · Pro 1,000 · Enterprise 4,000.
SIEM integrations
- Splunk App (custom)
- Elastic Security integration
- QRadar custom DSM
- ArcSight FlexConnector
Contact support for integration guides
Automation
- Slack/Mattermost notifications
- Jira ticket creation
- PagerDuty alerts
- Webhook integrations
Security tools
- Vulnerability scanners
- Threat hunting platforms
- SOC automation tools
- Risk assessment systems
Languages
- Python (requests, httpx)
- JavaScript/Node.js (axios, fetch)
- Go (net/http)
- PowerShell (Invoke-RestMethod)
Python helper class
class ThreatRadarAPI:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://radar.offseq.com/api/v1"
self.headers = {"X-API-Key": api_key}
def get_threats(self, **params):
response = requests.get(
f"{self.base_url}/threats",
headers=self.headers,
params=params
)
response.raise_for_status()
return response.json()
def search_threats(self, query, **params):
params['q'] = query
response = requests.get(
f"{self.base_url}/threats/search",
headers=self.headers,
params=params
)
response.raise_for_status()
return response.json()
def check_iocs(self, indicators):
response = requests.post(
f"{self.base_url}/threats/check-iocs",
headers=self.headers,
json={"indicators": indicators}
)
response.raise_for_status()
return response.json()
# Usage
api = ThreatRadarAPI("tr_your_api_key_here")
threats = api.get_threats(severity="critical", limit=10)Slack notification script
import requests
import json
def send_threat_alert_to_slack(webhook_url, threat):
message = {
"text": f"New {threat['severity'].upper()} Threat Detected",
"attachments": [
{
"color": "danger" if threat['severity'] == "critical" else "warning",
"fields": [
{"title": "Title", "value": threat['title'], "short": False},
{"title": "CVE", "value": threat.get('cveId', 'N/A'), "short": True},
{"title": "Severity", "value": threat['severity'].title(), "short": True},
{"title": "Product", "value": threat.get('product', 'N/A'), "short": True},
{"title": "Published", "value": threat.get('publishedDate', 'N/A'), "short": True}
]
}
]
}
requests.post(webhook_url, json=message)
# Monitor and alert
api = ThreatRadarAPI("tr_your_api_key_here")
threats = api.get_threats(severity="critical", limit=5)
for threat in threats['data']['threats']:
send_threat_alert_to_slack(SLACK_WEBHOOK_URL, threat)MCP server
Radar runs a hosted Model Context Protocol server at https://radar.offseq.com/mcp, so Claude, Cursor, VS Code and other MCP clients can query Radar and your account directly. It takes your API key in X-API-Key (or Authorization: Bearer) and works on every plan within its API quota; the decision tools need a paid plan, like the console ledger.
Set up
Claude Code
claude mcp add --transport http radar https://radar.offseq.com/mcp \ --header "X-API-Key: tr_your_api_key_here"
Cursor (.cursor/mcp.json)
{
"mcpServers": {
"radar": {
"url": "https://radar.offseq.com/mcp",
"headers": { "X-API-Key": "tr_your_api_key_here" }
}
}
}VS Code (.vscode/mcp.json)
{
"servers": {
"radar": {
"type": "http",
"url": "https://radar.offseq.com/mcp",
"headers": { "X-API-Key": "tr_your_api_key_here" }
}
}
}Claude Desktop (claude_desktop_config.json, via mcp-remote)
{
"mcpServers": {
"radar": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://radar.offseq.com/mcp",
"--header", "X-API-Key:${RADAR_API_KEY}"],
"env": { "RADAR_API_KEY": "tr_your_api_key_here" }
}
}
}Clients that only accept a URL with OAuth, such as claude.ai custom connectors, are not supported yet; mcp-remote passes the key for them.
Tools
| Tool | What it does |
|---|---|
| search_ | Filtered search: KEV, EUVD exploited, exploited but not in CISA KEV, exploit maturity, rising EPSS, minimum priority, sort. |
| get_ | One CVE or slug in full: priority factors, KEV, EPSS history, exploit sources, EUVD, source consensus. |
| match_ | purls, name + ecosystem + version or CPEs, with upgradeTo and unfixed per package. |
| scan_ | An SBOM or lockfile through the same pipeline as POST /api/v1/scan, with an optional failOn gate. |
| prioritize_ | Patch order for 2 to 50 CVEs, as POST /api/v1/prioritize. |
| check_ | Indicators of compromise against the indicators in threat reports. |
| get_ | The landscape briefing for 24h, 7d, 14d or 30d. |
| list_ | Your What changed feed. |
| list_ | Your host inventory and one host’s current findings. |
| list_ | Your decision ledger. |
| record_ | Records a decision. The only tool that writes. |
Every tool returns structuredContent matching its outputSchema and a short text summary that cites sources and the as-of time. Resources: radar://kev/recent, radar://threat/{cveId} and radar://openapi. Prompts: patch_decision, compare_and_prioritize and dependency_triage.
Quota and protocol
- Each
tools/call, and each read of aradar://kevorradar://threatresource, counts as one API request against your plan’s . initialize, ping and the tool, resource and prompt lists are free. A spent quota returns HTTP 429 with Retry-After. A call refused before it runs (invalid arguments, over a plan cap, an unknown resource) is not counted. - Every JSON-RPC message, free methods included, counts toward a limit of 120 per minute per API key; beyond it HTTP 429 with Retry-After and JSON-RPC error -32000.
- Per-call caps follow your plan: results Free 10 · Individual 25 · Base 50 · Pro 100 · Enterprise 100; packages Free 50 · Individual 200 · Base 200 · Pro 1,000 · Enterprise 4,000.
- Streamable HTTP with JSON responses; protocol versions 2025-06-18 and 2025-03-26 (batches of up to 32 messages, only in 2025-03-26; once the quota refuses a call, the batch’s later charged calls are refused too). Stateless: no Mcp-Session-Id is issued and GET returns 405. Notifications alone return 202 with no body.
- Transport errors: an
MCP-Protocol-Versionheader naming another version returns 400 (-32600); malformed JSON or a body that is notapplication/jsonreturns 400 with -32700; a body over 10 MB returns 413; a browser request from anotherOriginreturns 403. - A missing or invalid key returns HTTP 401 (403 for an incomplete profile or a plan without API access) with
WWW-Authenticate: Bearer realm="radar"and JSON-RPC error -32001. - Text summaries quote third-party feed content (titles, descriptions, remediation) in «» so the model treats it as data, not instructions.
- Calls appear in your API request log under
/mcp.
Webhook integration
Webhooks allow you to receive real-time HTTP POST notifications when threats match your custom feeds. Configure webhooks in Console → Automations (Individual plan and up).
Delivery format
When a webhook fires, Threat Radar sends an HTTP POST request to your configured URL with a JSON body.
Request headers
Content-Type: application/json User-Agent: ThreatRadar-Automations/1.0 X-Threat-Radar-Event: custom_feed_alert | starred_threat | host_finding | decision_stale | test X-Threat-Radar-Timestamp: <unix seconds> X-Threat-Radar-Signature: v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>" keyed with your secret> X-Threat-Radar-Secret: <your-webhook-secret> (legacy; will be removed)
Verify requests with the X-Threat-Radar-Signature header: recompute HMAC-SHA256 over timestamp + "." + raw body with the secret from your webhook settings, compare in constant time, and reject timestamps older than five minutes. The plaintext X-Threat-Radar-Secret header is still sent for older receivers. Any custom headers you configured are also included.
Event types
| Event | Description |
|---|---|
custom_feed_alert | New threats matched one of your custom feeds |
starred_threat | A threat was starred or unstarred |
decision_stale | Recorded decisions went stale, expired or came due for review; data.decisions[] carry cveId, verdict, status and reasons |
test | Manual test event triggered from the Console |
Payload: custom_feed_alert
{
"event": "custom_feed_alert",
"generatedAt": "2026-05-18T12:00:00.000Z",
"user": {
"id": "abc123",
"email": "user@example.com"
},
"data": {
"feed": {
"id": "feed_id",
"name": "My Critical Alerts",
"description": "All critical vulns",
"filters": { "severity": ["critical"], "dateRange": "7d" },
"alertsEnabled": true,
"createdAt": "2026-05-01T00:00:00.000Z"
},
"threatCount": 3,
"threats": [
{
"id": "threat_id",
"title": "CVE-2026-12345: RCE in Example Product",
"slug": "cve-2026-12345-rce-in-example-abc123",
"severity": "critical",
"type": "vulnerability",
"source": "CVE Database V5",
"publishedAt": "2026-05-18T08:00:00.000Z",
"cve": "CVE-2026-12345",
"vendor": "ExampleCorp",
"product": "Example Product",
"description": "Remote code execution via ...",
"tags": ["cve", "rce"],
"url": "https://radar.offseq.com/threat/cve-2026-12345-..."
}
]
}
}Payload: starred_threat
{
"event": "starred_threat",
"generatedAt": "2026-05-18T12:00:00.000Z",
"user": { "id": "abc123", "email": "user@example.com" },
"data": {
"threat": {
"id": "threat_id",
"title": "CVE-2026-12345: RCE in Example Product",
"slug": "cve-2026-12345-rce-in-example-abc123",
"severity": "critical",
"type": "vulnerability",
"source": "CVE Database V5",
"publishedAt": "2026-05-18T08:00:00.000Z",
"cve": "CVE-2026-12345",
"vendor": "ExampleCorp",
"product": "Example Product",
"description": "Remote code execution via ...",
"tags": ["cve", "rce"],
"indicators": [],
"url": "https://radar.offseq.com/threat/cve-2026-12345-..."
},
"star": {
"notes": "Affects our prod servers",
"tags": ["internal", "p0"],
"priority": "critical",
"starredAt": "2026-05-18T12:00:00.000Z"
},
"action": "starred"
}
}Payload: test
{
"event": "test",
"generatedAt": "2026-05-18T12:00:00.000Z",
"user": { "id": "abc123", "email": "user@example.com" },
"data": {
"note": "Manual webhook test event",
"triggeredBy": "user@example.com"
}
}Receiver example (Node.js / Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
const WEBHOOK_SECRET = 'trwh_your_secret_here';
app.post('/webhooks/threat-radar', (req, res) => {
// 1. Verify the signature (HMAC-SHA256 over "timestamp.body")
const ts = req.headers['x-threat-radar-timestamp'];
const sig = String(req.headers['x-threat-radar-signature'] || '');
const expected = 'v1=' + crypto.createHmac('sha256', WEBHOOK_SECRET).update(`${ts}.${req.rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
if (!fresh || sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { event, data } = req.body;
// 2. Handle the event
switch (event) {
case 'custom_feed_alert':
console.log(`Feed "${data.feed.name}": ${data.threatCount} new threats`);
for (const threat of data.threats) {
console.log(` - [${threat.severity}] ${threat.title}`);
}
break;
case 'starred_threat':
console.log(`Threat ${data.action}: ${data.threat.title}`);
break;
case 'test':
console.log('Test event received');
break;
}
// 3. Respond with 200 to acknowledge receipt
res.status(200).json({ received: true });
});
app.listen(3000);Receiver example (Python / Flask)
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = "trwh_your_secret_here"
@app.route("/webhooks/threat-radar", methods=["POST"])
def handle_webhook():
# 1. Verify the request
secret = request.headers.get("X-Threat-Radar-Secret", "")
if secret != WEBHOOK_SECRET:
return jsonify({"error": "Invalid secret"}), 401
payload = request.get_json()
event = payload.get("event")
data = payload.get("data", {})
# 2. Handle the event
if event == "custom_feed_alert":
feed = data["feed"]
print(f"Feed '{feed['name']}': {data['threatCount']} new threats")
for threat in data["threats"]:
print(f" - [{threat['severity']}] {threat['title']}")
elif event == "starred_threat":
print(f"Threat {data['action']}: {data['threat']['title']}")
elif event == "test":
print("Test event received")
# 3. Respond with 200
return jsonify({"received": True}), 200Important notes
- Your endpoint must respond within 8 seconds or the delivery is marked as failed.
- Return any
2xxstatus code to acknowledge receipt. - Failed deliveries are not retried automatically. Check delivery logs in Console → Automations.
- Use the Test button in webhook settings to verify your endpoint before going live.
- The webhook secret is included as-is in the header (not HMAC). Rotate it via Console if compromised.