Time-series
The FIRST EPSS API exposes per-CVE history via the scope=time-series
query parameter. epss.cr flattens the nested response into a regular
Array(Score) — one entry per day — so the rest of your code doesn't
need a special case.
Quick history
client = EPSS::Client.new
series = client.time_series("CVE-2022-27225")
series.size # => 31 (today + 30 prior days)
series.first.date.not_nil!.to_s("%Y-%m-%d") # oldest
series.last.date.not_nil!.to_s("%Y-%m-%d") # most recent
#time_series sorts the results oldest-first.
What the API returns
For scope=time-series, FIRST wraps each CVE's history inside the data
row:
{
"data": [{
"cve": "CVE-2022-27225",
"epss": "0.001870000",
"percentile": "0.401290000",
"date": "2026-05-18",
"time-series": [
{"epss": "0.001870000", "percentile": "0.401770000", "date": "2026-05-17"},
...
]
}]
}
EPSS::Response.from_json automatically:
- Promotes the parent row's
{cve, epss, percentile, date}to aScore. - Expands every entry of
time-seriesinto its ownScore, copying the parent'scve.
So an EPSS::Response for a time-series query carries ~30 Scores per
queried CVE in its scores array.
Plotting / charting
Because each Score is a regular value object with cve, epss,
percentile, and date, plotting libraries can ingest the series
directly:
points = client.time_series("CVE-2022-27225").map do |s|
{x: s.date.not_nil!.to_unix, y: s.epss}
end
Time-series + filters
You can combine scope=time-series with threshold filters. The
flattening preserves filter behavior because the server pre-filters CVEs
before expanding history.
query = EPSS::Query.new(
cves: ["CVE-2022-27225", "CVE-2021-44228"],
scope: "time-series",
)
client.fetch(query).scores.size # ~31 days × 2 CVEs ≈ 62
Pagination
Client#each_score advances by the server-reported row count
(Response#row_count) rather than the flattened score count, so paging
through time-series queries never skips a CVE.