No API key required. Two limits apply to the endpoints on this page, and the tighter one is the one you meet. Our WAF counts every request you make as one caller (defined below) to the API in a 60-second window that starts with your first request — all paths together, across every serving instance — and refuses a request once that count passes the ceiling for the path it is on:
- 12,000 requests per minute on
/api/v1/public/cves and every path under it (200 per second sustained) - 6,000 requests per minute on
/api/v1/public/vendor-advisories, /api/v1/public/cwes, /api/v1/public/ecosystems, /api/v1/public/vendors, /api/v1/public/shadow-ai-radar and /api/v1/public/kev/recent (100 per second sustained) - 600 requests per minute on any other
/api/v1/public path (10 per second sustained)
Because the count is shared, a heavy run on /api/v1/public/cves also uses up the lower ceilings: after 6,000 requests in one window, /api/v1/public/vendor-advisories refuses you too.
On top of that ceiling, 500 requests per second per caller is a burst cap, counted once across every serving instance (an instance counts on its own only while our shared counter is unreachable), so it bounds a burst rather than your sustained rate. For both limits, one caller is one client address as our platform records it on arrival — an IPv4 address, or for IPv6 the /64 network the address is in — and not a value you send in a header, so other callers' traffic does not spend your allowance. Clients behind one shared IPv4 address or one IPv6 /64 (an office NAT, a proxy relaying for others, a hosting provider that puts several customers in one /64) share that one allowance, and a host rotating its IPv6 privacy addresses stays one caller. One exception: a request that reaches the API from inside Google Cloud without a public address (for example, from a VM with no external IP, using Private Google Access) arrives with no client address our platform records, so every such caller is counted as the same caller and all of them share one per-second allowance. Cloud NAT does not change this; to get an allowance of your own, give the workload an external IP address.
What you see: an admitted response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Window: 1s and X-RateLimit-Reset for the per-second limit only, and X-RateLimit-Remaining there is your count across every serving instance. The WAF's per-minute count is not shown until it refuses you. Over either limit you get a 429 with Retry-After in seconds. A WAF refusal carries X-RateLimit-Window: 60s, X-RateLimit-Limit set to that path's per-minute ceiling, and a JSON body whose code is RATE_LIMIT_EXCEEDED; a refusal by the per-second limit carries X-RateLimit-Window: 1s. Need higher throughput? Get in touch.
GET/api/v1/public/cves
The main feed — paginated JSON list, newest first. Params: limit (default 50, max 200), offset, search, severity, min_cvss, kev_only, year, sort (published | epss | severity | cvss_v3_score), skip_count. reverse=true reads the default listing (sort=published or no sort, with no status other than active, and no severity, min_cvss, since, until, year, kev_only or search filter) from its far end — offset and limit count back from the last row, and the page comes back in the usual order with "reverse": true in the response — so a page near the end costs about what a page near the start does; with any other sort or filter, or with the before_published / before_cve_id cursor, it is refused with a 400. Every list response carries reverse_available, true exactly when the same request with reverse=true would be served, so a pager can ask the server instead of copying that rule; take the page count from a counted response (not one sent with skip_count). kev_only=true restricts to CVEs CISA lists as known-exploited; year filters on UTC publication year. There is no vendor filter on this endpoint — vendor lives in CPE match data, so use /api/v1/public/cves/match?vendor=&product= for that; passing vendor here returns 400 rather than silently ignoring it. severity filters on NVD's CVSS v3 label by default; pass severity_basis=eg to filter on the EchelonGraph severity instead — that is the basis the /pulse counts use, and it is the only way to reach the ~72,000 CVEs NVD never rated under v3. min_cvss matches the highest CVSS we hold (v3.1, v4.0 or v2.0). Response: cves for this page, plus total and two booleans about it, total_is_lower_bound and total_counted. total is how many CVEs match the filter across every page, not how many this page holds. Both booleans are always present, in both polarities — branch on their value, never on whether the field exists. total_is_lower_bound=true means total is a proven floor, not a count: at least that many match, and the exact figure could not be computed inside the query's execution budget. That happens on a free-text search matching a very large share of the corpus; a narrower search gets an exact total. total_counted=false means no count is in hand — because you sent skip_count=1 (for bulk paginators that never show a total), because you used the keyset cursor path (before_published + before_cve_id), or because the server could not count the matches inside the query's execution budget, not even to a floor — and total is 0 and means nothing. With skip_count or the cursor, page with the next_published / next_cve_id the response carries instead; when the budget was the cause the response is an ordinary offset page with its rows and no cursor, and a narrower search gets a total. search_relaxed=true means the rows are for a widened search, not the one you sent: the search term was a phrase — a quoted string, or a hyphenated compound such as cross-site scripting, which the parser reads as adjacent words — and its exact match could not finish inside the query's execution budget, so it was relaxed to a conjunction of the same words in any order; the rows, and total when total_counted is true, are that conjunction's. Always present, in both polarities; false means the search ran as sent. Rate limits: 12,000 requests per minute on /api/v1/public/cves and every path under it (200 per second sustained), counted by our WAF for each caller across all paths; on top of that, 500 requests/second per caller is a burst cap, counted once across every serving instance. For both limits one caller is one IPv4 address, or for IPv6 one /64 network, as our platform records it, never a header you send. Successful responses show only the per-second limit's X-RateLimit-* headers; the paragraph above this list says how both limits are counted and what a 429 from each carries.
curl "https://app.echelongraph.io/api/v1/public/cves?severity=HIGH&severity_basis=eg&min_cvss=7&limit=20"
GET/api/v1/public/cves/summary
Overall counts and the live NVD poller's health snapshot, updated continuously. Severity buckets (critical/high/medium/low/unscored) use the EchelonGraph severity — the band we publish — and always sum to total. NVD's own CVSS v3 classification is returned alongside as nvd_critical/nvd_high/nvd_medium/nvd_low/nvd_none, so you can compare the two bases.
curl https://app.echelongraph.io/api/v1/public/cves/summary
GET/api/v1/public/cves/{id}
Single CVE record — the merged view: NVD CVSS, EchelonGraph synthesised score, KEV/EPSS signals, GHSA references, vendor advisory cross-links. For the ~72,000 pre-2016 CVEs that NVD only ever rated under CVSS v2, cvss_v3_score is 0 and the real rating is in cvss_v2_score / cvss_v2_severity / cvss_v2_vector — NVD maps a v2 score to three bands only (LOW 0.0-3.9 / MEDIUM 4.0-6.9 / HIGH 7.0-10.0, no CRITICAL; CVSS v2 itself defines none) and is NOT comparable to a v3 score. exploit_poc_available is true when EchelonGraph holds at least one reference to public exploit or proof-of-concept code for the CVE: a Metasploit module, an Exploit-DB entry or a GitHub proof-of-concept repository. Nuclei templates do not count, nor does a GitHub repository that names or describes itself as a checker or scanner (exploit kind github_checker): each is a detection check that tests whether a system looks affected, and is not necessarily exploit code. false means no such reference is on record, not that none exists, and neither value is a statement about exploitation in the wild: that is kev_listed, and eg_kev_tier 1. A REJECTED CVE ID no longer describes a vulnerability, so its exploit_poc_available is false and its eg_kev_tier null, whatever references it holds. For a CVE on CISA's Known Exploited Vulnerabilities catalog, three fields carry CISA's own text, verbatim: kev_required_action (CISA's requiredAction, directed at US federal civilian agencies under Binding Operational Directive 26-04, which replaced BOD 22-01 on 10 June 2026, and not EchelonGraph's advice), kev_short_description (CISA's shortDescription) and kev_notes_urls (the https URLs in CISA's notes, in CISA's order, at most 10). Each is absent where CISA gives none, and a CVE that is not KEV-listed carries none of them; only this endpoint serves them, not the list or the KEV feed. {id} may also be another identifier of the CVE: an ENISA EUVD id (EUVD-2026-90503), a GitHub advisory id (GHSA-…), an OSV id that aliases a CVE (PYSEC-, GO-, RUSTSEC-, GSD-, MAL-) or a GCVE-0 id (GCVE-0-2024-3094, which is CVE-2024-3094 by the GCVE specification). The answer is the CVE's own record, 200, with resolved_from { id, namespace, source, attribution } naming the identifier asked for and what mapped it, and a Content-Location header naming the CVE's own URL; an identifier EchelonGraph does not hold is a 404 (ALIAS_NOT_FOUND), one that names more than one CVE a 300 (ALIAS_AMBIGUOUS, with the candidates), and a 503 (ALIAS_INDEX_UNAVAILABLE) means the alias index could not be read. aliases lists the CVE's other identifiers, each { id, namespace, source, first_seen_at, attribution }; source is enisa_euvd (ENISA's European Vulnerability Database), github or osv. eu_kev is present when ENISA's EU Known Exploited Vulnerabilities catalogue lists the CVE: { listed, date_added (ENISA's date), reported_by (the reporting source as ENISA records it), euvd_id, vendor_project, product, first_seen_at, source, attribution }; only this endpoint serves it. ENISA's data — EUVD identifiers and the eu_kev object — is reproduced per record with acknowledgement (attribution, which begins "Source: ENISA EUVD") and is not relicensed; it is served only while EchelonGraph displays EUVD data, and no endpoint lists the eu_kev object or EUVD identifiers in bulk. List rows and MCP search_cves carry, per CVE, score_factors.eu_kev { listed, source, attribution } and Rule 1b's rule and rationale, each attributed; the bulk export carries no factors, so its echelongraph_score for a CVE only ENISA's EU KEV lists reflects that listing (Rule 1b's 9.0 floor) without naming it.
curl https://app.echelongraph.io/api/v1/public/cves/CVE-2021-44228
GET/api/v1/public/cves/match
Whether a product or a package at a version is affected by known CVEs: the matcher behind /am-i-affected. Send product (an NVD CPE product token) and version, or ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, in the X-EG-Product, X-EG-Version, X-EG-Ecosystem and X-EG-Package request headers (percent-encoded UTF-8), so the lookup is in no request log that records URLs; the same names as query parameters still work. Read assessed before count: assessed false means the lookup did not evaluate the component, not_assessed_reason says why, and a count of 0 then is not "not affected". Each registry match carries interval, the advisory interval that holds the version (introduced, and fixed, exclusive, or last_affected, inclusive), and fixed_in, the lowest version above the one asked about that no affected interval of the same record holds: the holding interval's fixed bound, moved past any overlapping interval that still holds it, which fixed_in_detail then names. It is always above the version asked about, and null where no such version is recorded (the walk reaches an interval that ends at last_affected, or has no end, with none beyond it) or no interval holds the version, and fixed_in_reason then says which. A null fixed_in is not a finding that no fix exists. A CPE match carries no fixed_in: the end of a CPE version range is not a fix, so it is given in its own field, cpe_version_end_excluding (the first version the matched NVD range no longer holds) or cpe_version_end_including (the last one it holds).
curl -H 'X-EG-Ecosystem: Maven' -H 'X-EG-Package: org.apache.logging.log4j:log4j-core' -H 'X-EG-Version: 2.14.1' https://app.echelongraph.io/api/v1/public/cves/match
POST/api/v1/public/cves/match/batch
The registry match for up to 200 components in one call: a JSON body of {"components": [{"purl": …} or {"ecosystem", "package", "version"}]} and nothing else (a CycloneDX or SPDX document posted whole is refused with a 400). Each row of results keeps the single match's answer, assessed and not_assessed_reason among them, each match with interval, fixed_in and fixed_in_reason as above. Each row also names the package its advisories were looked up by, matched_package, and how, matched_via: purl_name (the purl's own name), coordinates (the component's package), or upstream, for a deb, apk or alpine purl whose upstream qualifier (upstream=name or upstream=name@version) names the source package, which is how Debian and Alpine file advisories; an upstream row also carries purl_name, the purl's own (binary) name, and matched_version_from (upstream when the qualifier's version was used, purl when the purl's was). A deb, apk or rpm purl without a distro qualifier naming its release is not assessed (distro_release_unknown). The route is charged per component: 1,200 components a minute per caller.
curl -X POST -H 'content-type: application/json' -d '{"components":[{"purl":"pkg:apk/alpine/libcrypto3@3.3.6-r0?distro=alpine-3.20&upstream=openssl"}]}' https://app.echelongraph.io/api/v1/public/cves/match/batchGET/api/v1/public/cves/{id}/related
Three lists of related CVEs, up to 10 each. same_product: CVEs sharing an affected package, most specific shared package first. same_vendor: CVEs sharing a vendor advisory, most shared advisories first. same_cwe: CVEs sharing a CWE, nearest in publish date first. EchelonGraph score only breaks ties. basis says why each list is what it is: ok, no_data (this CVE has no rows for that relation), no_siblings (it has rows, but no other CVE shares them), no_specific_key (same_product only: every package it shares is carried by too many CVEs to count as evidence), capped (same_vendor and same_cwe: siblings exist, but only through keys beyond the first 50 this lookup reads), or unknown (the reason was not established in time; the list itself is still correct). Powers the sidebar on every /pulse/{id} page.
curl https://app.echelongraph.io/api/v1/public/cves/CVE-2021-44228/related
GET/api/v1/public/cves/{id}/references
Per-reference enrichment for one CVE — vendor advisory cross-walks, patch URLs, exploit POC indicators where surfaced.
curl https://app.echelongraph.io/api/v1/public/cves/CVE-2021-44228/references
GET/api/v1/public/cves/{id}/enrichment
One CVE's enrichment sections in one answer, among them vendor advisories, patches, fixed versions, affected packages, CWEs, public exploit references and the enrichment timeline. failed_sections names any section that could not be read, served empty. Each affected_packages row carries fixed_branches, every affected range of the package with its fix ({introduced, fixed, last_affected, source, advisory_id}: fixed null where no fix is on record, last_affected null where the range ends at a fix, advisory_id the OSV record that published the range, in that record's order; fixed_branches null where the package's ranges were never loaded, [] where no version range is on record, which is not a finding that no fix exists). fixed_version on that row is one range's fix, kept for compatibility: it is not the fix for every affected version (CVE-2021-44228's log4j-core: 2.12.2, while 2.14.1 is in the range fixed in 2.15.0), so do not read it as yours. The fix for your version is the lowest version above it that no range in fixed_branches holds, in the package ecosystem's own version order: start from the fixed bound of a range your version is in, and move past any range that still holds that bound. Where the walk reaches a range that ends at last_affected, or has no end, with none beyond it, no fix is on record for your version. /api/v1/public/cves/match does this walk for you and returns the answer as fixed_in.
curl https://app.echelongraph.io/api/v1/public/cves/CVE-2021-44228/enrichment
GET/api/v1/public/cves/trends
Weekly + monthly volume + week-over-week delta + severity distribution. Backs the dashboard cards on /pulse.
curl https://app.echelongraph.io/api/v1/public/cves/trends
GET/api/v1/public/cves/export.ndjson
Streaming NDJSON bulk export — one CVE per line. Filters: year, severity, kev_only, min_cvss. min_cvss matches the HIGHEST CVSS we hold for a CVE (v3.1, v4.0 or v2.0), so a v2-only 10.0 is included where a v3-only filter would have missed it. Hard cap 50,000 rows per call (iterate via the year param for the full dataset).
curl "https://app.echelongraph.io/api/v1/public/cves/export.ndjson?year=2024&severity=CRITICAL&kev_only=true" | jq -c .
GET/api/v1/public/vendor-advisories
Vendor-disclosed security advisories (Microsoft MSRC, Red Hat RHSA, GitHub GHSA, Cisco PSIRT, AWS, GCP, GitLab). Many appear here before NVD assigns a CVE-ID.
curl https://app.echelongraph.io/api/v1/public/vendor-advisories?has_cve=true&limit=50
GET/api/v1/public/vendor-advisories/{vendor}/{advisory_id}
Single vendor-advisory detail — title, description, CVSS, affected products, remediation, references, linked CVE IDs.
curl https://app.echelongraph.io/api/v1/public/vendor-advisories/github/GHSA-99gv-2m7h-3hh9