Docs / Cyber Threat Data API / Endpoints / Score a list of CVEs
Score a list of CVEs
Scores a LIST OF CVEs rather than paging a collection, and that is the one place this API works differently from everywhere else. Send up to 100 ids in `?cve=`, comma-separated or as a repeated parameter, and you get back the score in force for each one that FIRST publishes a score for. There is no `limit`, no `cursor` and no `meta.next`, because there is no walk: the corpus is over 377,000 CVEs re-scored every night, and taking all of it is a job for the one file FIRST publishes daily — whose URL `GET /v1/cyber/epss/snapshot` hands you. A 101st id is a `400` naming the count rather than a truncation, because the ids that got dropped would come back in `meta.not_found`, which means something else entirely. `meta.not_found` is the field to read and it is always there, `[]` included. A CVE with no score is an ordinary answer — FIRST scores published CVEs and the corpus moves — and this endpoint exists so that “there is genuinely no score for this” and “the lookup did not work” stop arriving as the same short array. `epss` is a probability in [0, 1] that the CVE will be exploited in the next 30 days. It is NOT a percentage and NOT a severity: 0.00042 is four in ten thousand, and it says nothing about how bad exploitation would be. `percentile` is the field to compare across days, because the raw score is re-scaled whenever FIRST retrains and every row carries the `model_version` that produced it. These scores are published by FIRST under CC BY 4.0. If you publish them onward, reproduce the attribution string the snapshot endpoint serves.
Query parameters
| Parameter | Type | Description |
|---|---|---|
cve required |
string | **Required.** Up to 100 CVE ids, comma-separated — `CVE-2021-44228,CVE-2014-0160` — or the parameter repeated, which means the same thing. Case is normalised. A 101st id is a `400` naming the count rather than a silent truncation, because the ids that were dropped would come back in `meta.not_found`, which means "FIRST publishes no score for this CVE" and would be false about them. There is no way to page the whole score set here and that is deliberate: it is over 377,000 rows re-scored daily, and `GET /v1/cyber/epss/snapshot` hands you the URL of the one file FIRST publishes it in. |
Request
curl -G https://api.softon.dev/v1/cyber/epss \
-H "Authorization: Bearer $SOFTON_KEY" \
-d cve=CVE-2021-44228,CVE-2014-0160
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.softon.dev/v1/cyber/epss", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SOFTON_KEY")) q := req.URL.Query() q.Set("cve", "CVE-2021-44228,CVE-2014-0160") req.URL.RawQuery = q.Encode() res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() var page struct { Data []Eps `json:"data"` Meta struct{ Requested, Answered int } `json:"meta"` } if err := json.NewDecoder(res.Body).Decode(&page); err != nil { log.Fatal(err) }
import json, os, urllib.parse, urllib.request url = "https://api.softon.dev/v1/cyber/epss" + "?" + urllib.parse.urlencode({ "cve": "CVE-2021-44228,CVE-2014-0160", }) req = urllib.request.Request(url, headers={ "Authorization": "Bearer " + os.environ["SOFTON_KEY"], }) page = json.load(urllib.request.urlopen(req)) # page["data"] is the object; page["error"] is None on success
const url = new URL("https://api.softon.dev/v1/cyber/epss"); url.searchParams.set("cve", "CVE-2021-44228,CVE-2014-0160"); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SOFTON_KEY}` }, }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { data, meta } = await res.json();
Response
The envelope is identical on every softon.dev API: data, meta, error. Only the shape inside data changes per dataset — see the response envelope.
{
"data": [
{
"id": "epss:2026-09-20:CVE-2021-44228",
"source": "epss",
"score_date": "2026-09-20",
"model_version": "v2026.06.15",
"cve_id": "CVE-2021-44228",
"epss": 0.99999,
"percentile": 1.0,
"upstream_url": "https://epss.empiricalsecurity.com/epss_scores-2026-09-20.csv.gz",
"checksum_sha256": "6e5d46d4fa7f7229fa41813e01ea9a56221fa082244323470a70911e984275b6",
"ingested_at": "2026-09-21T02:00:41.207318Z"
}
],
"meta": { "requested": 2, "answered": 1, "not_found": ["CVE-2026-00000"], "request_id": "req_9Fv3" },
"error": null
}
Fields of each item in data[]
| Field | Type | Description |
|---|---|---|
id |
string | Stable identifier, "<source>:<score date>:<CVE id>" — `epss:2026-09-20:CVE-2021-44228`. Opaque: treat the whole string as the id. **There is no `GET /v1/cyber/epss/{id}`, and that is deliberate** — a superseded score is pruned, so an id you stored last week names a row that is gone, and a by-id route would be a promise of permanence this dataset does not make. Ask for the CVE instead: `?cve=` always answers with the score in force. |
source |
string | Which scraper delivered this score. `epss` is FIRST's own published file. |
score_date |
date | The day FIRST produced this score — the as-of, and half the id. **Read it per row rather than assuming one value for the response.** A daily snapshot lands as nineteen batches, so a run that failed part-way leaves some rows on the previous day, and each row says which. |
model_version |
string | The trained model that produced the score, `v2026.06.15`. On every row because **scores are not comparable across a retrain** — FIRST re-scales, so a raw score from two different model versions is two different scales. `percentile` is the field to compare across days. |
cve_id |
string | The CVE identifier, `CVE-2021-44228`, upper-case as published. |
epss |
number | **The probability this CVE will be exploited in the wild in the next 30 days, in `[0, 1]`.** Not a percentage: `0.00042` is four in ten thousand, and multiplying by 100 somewhere and forgetting further down is how a backlog gets sorted by a number a hundred times too large. Served at the precision FIRST published — up to five decimals, unrounded, never padded, so `0.5` and `0.00047` both occur. It is **not a severity and not CVSS**, and it says nothing about impact. `/v1/cyber/kev` is the complementary fact: evidence that exploitation has already happened. |
percentile |
number | Where this score ranks against every other scored CVE, in `[0, 1]`. **This is the field to compare across days**, because it is a rank within its own model run where the raw score moves when the model is retrained. `0.0` and `1.0` both occur. |
upstream_url |
string | The dated file these numbers were read out of, after the redirect chain — not the `-current` pointer, which names no day. Download it and you have FIRST's own copy of this snapshot. **FIRST keeps every day's file at a URL of this shape**, which is why this platform serves only the current score per CVE rather than mirroring the archive. |
checksum_sha256 |
string | `sha256` of that file's **gzipped bytes as served**, so your `sha256sum` of the download and this value are the same string. A digest of a decompressed copy would be a statement about someone's gunzip instead. |
ingested_at |
timestamp | When this platform stored the score. **Not the as-of** — `score_date` is — and the two differ by the time between FIRST publishing and the daily run reading it. |
Try it
Send the request to see a response.