GET /aggregate
A time series over the match set.
Per calendar bucket (hour, day, week, month, or `all`), the article count and optionally the distinct story count, the distinct outlet count and a signal's mean and percentiles -- over the same match set /search would rank, keyword-matched. One request replaces one search per day. `interval` is required; `from` is filled in from the plan's archive window when omitted, and a request without a key must pass it. At most 366 buckets. Equivalent to the `aggregate_news` MCP tool. Credits: 1, whatever the number of buckets; +1 with event_type; +1-2 per entity_id. Pass estimate=true to get the exact range without running the call, or max_credits to refuse anything costlier.
"How did attention move?" is a series, not a list. This endpoint takes the query and filters
/search takes, cuts the window into calendar buckets, and counts inside each
one. A fortnight by day is one request, not fourteen.
Parameters
| Name | Type | Description |
|---|---|---|
| interval required | `hour` · `day` · `week` · `month` · `all` | Bucket width, aligned to the calendar in UTC: hour, day, week (Monday), month, or `all` for one bucket over the whole range. At most 366 buckets. |
| metrics | string | Comma-separated: `articles` (always reported), `stories` (distinct story ids), `outlets` (distinct publisher domains), `signal:<name>` (mean and percentiles of one signal). Default `articles`. |
| percentile | number | An extra percentile to report for every `signal:` metric, 1-99.9; the 50th and 95th are always reported. |
The match set is the one /search would rank, keyword-matched: q, every filter, the
signal ranges and the time bounds mean what they mean there. Each group is described in full on the page linked
beside it.
- Ranking
-
q - Filters
-
sourcesource_typepublisher_countrylanguagestory_idtopicorganizationpersoncountryauthorlocationlocation_idcityregionnamementioned_datequote_verbamount_objectestimatemax_credits - Entity ids
-
entity_id - Event classes
-
event_type - Signals
-
signalsignal_minsignal_maxsignals - Time range
-
fromto
event_type counts one event class over time. near, radius_km, bbox, amount_min and amount_max are not taken here.
Buckets
interval is required: hour, day, week, month, all. Buckets are
aligned to the calendar in UTC, not to your from. A day runs from midnight to midnight UTC, a week
starts on Monday, a month on the first. all is one bucket over exactly your range: one article count,
one distinct story count and one distinct outlet count for the window.
Each bucket's from is inclusive and its to exclusive, as 14-digit
YYYYMMDDHHMMSS stamps. Buckets come back oldest first, and an empty bucket is reported with zero
counts rather than left out, so a gap in the series is a zero you can plot.
The first and last buckets are labelled with their whole calendar period, but count only what falls inside your
from and to. Ask for weeks from a Wednesday and the first bucket reads from the Monday
before, holding five days of articles rather than seven. A short edge bucket is not a quiet one. Start
from on a bucket boundary when the series will be compared bucket to bucket.
A series is capped at 366 buckets. A year by day fits; a week by hour fits; a quarter by hour does not, and is
refused with 400 and code: invalid_parameter before any index work. Narrow the range or
widen the interval.
The window
Pass from. With a key, an omitted from is filled in from your plan's archive window, and
the series starts at the oldest day your plan can reach. A session without a plan (the
local stdio server in open mode) has no window to fill in, and is refused without one.
Either way, name the window you mean: a series over the whole archive is rarely the question, and it is the
slowest one to answer. A from older than your plan allows is pulled forward, and
from_clamped says so.
What is counted
metrics is a comma-separated list. articles is always reported; the rest ride on the
same request.
-
articles: matching articles published in the bucket. Exact. It counts copies: one wire story on forty domains is forty articles. -
stories: distinctstory_ids among those articles. The number of events, which is usually the series you want to plot. An article the clusterer left alone is its own story. -
outlets: distinct publisher domains. Domains, not newsrooms, for the reason/storiesgives. -
signal:<name>: one signal's intensity across the bucket, as a mean and percentiles. Repeat it for several signals:metrics=signal:conflict,signal:energy.
When a count is approximate
articles is a sum, and sums are exact. stories and outlets are distinct
counts, and a distinct count cannot be added up. The corpus is split across indexes by time; each one counts its
own distinct values, and the API sums them. When two indexes contribute to one bucket, a story or an outlet
present in both is counted twice. The usual case is a bucket that crosses an index boundary, such as a month
bucket that spans the end of one and the start of the next. A very large bucket can also exceed the count's
cap. Either way, the bucket says approximate: true.
Read it as an upper bound. For a figure you will quote, narrow the bucket (a week inside one month, say), or
cross-check one window against /stories.
Signal percentiles
A signal:<name> metric adds an entry to the bucket's signals, keyed by the signal
name: how many articles in the bucket carry it, their mean intensity, and the intensity at the 50th and 95th
percentiles. percentile adds one more, for every signal you asked for. mean is
null when no article in the bucket carries the signal.
The 95th percentile is the one to watch. A mean moves slowly, because most coverage of anything is routine; a
95th percentile that jumps says the sharpest coverage got sharper. When several indexes contribute to a bucket,
the percentiles are a count-weighted mean of theirs, and the entry says approximate: true.
Response
| Field | Type | Description |
|---|---|---|
| attribution | string | Required source attribution for any use of these results. |
| buckets | AggregateBucket[] | One entry per bucket, oldest first; empty buckets are reported with zero counts. |
| coverage | `complete` · `timed_out` · `range_too_wide` · `recent_unavailable` · `field_unavailable` | Why the answer is or is not complete, named for what you can do about it. `complete`: everything in range was read (or provably could not have changed the page). `timed_out`: the query ran out of budget -- retry, or narrow the range. `range_too_wide`: the range spans more of the corpus than one request may read -- narrow it; retrying unchanged will not help. `recent_unavailable`: the most recent data in range could not be read -- retry shortly; widening the range will not help. `field_unavailable`: a filter needs a field part of the index was built without (geographic, event-class and amount fields arrive with each shard's rebuild) -- the answer covers only the rebuilt part; retrying before the rebuild will not help. |
| credits_charged | integer | Index queries this answer took, and what the call is charged. |
| from | string | Start of the first bucket. |
| from_clamped | boolean | Whether `from` was moved forward to the plan's boundary; the series then starts there. |
| history_days | integer | How far back the caller's plan may query; null = the full archive. |
| interval | `hour` · `day` · `week` · `month` · `all` | The bucket width that was applied. |
| metrics | string[] | The metrics reported, as requested (`articles` always). |
| partial | boolean | Whether this answer is incomplete. `true` means part of the index could not be read, so an empty or short result set is NOT evidence of absence -- retry rather than caching it as a negative. |
| resolved | ResolvedEntity[] | Present when `entity_id` was used: the spellings each id matched. |
| to | string | End of the last bucket, exclusive. |
AggregateBucket
stories, outlets and signals appear only when you asked for them.
| Field | Type | Description |
|---|---|---|
| approximate | boolean | Whether `stories`/`outlets` are sums of per-index distinct counts or came from a capped count rather than one exact global count. |
| articles | integer | Matching articles published in the bucket. |
| from | string | Start of the bucket, inclusive, 14-digit YYYYMMDDHHMMSS UTC. |
| outlets | integer | Distinct publisher domains, when `outlets` was requested. |
| signals | object | Per requested `signal:<name>` metric, keyed by the signal name. |
| stories | integer | Distinct story ids, when `stories` was requested. |
| to | string | End of the bucket, exclusive. |
SignalStats
| Field | Type | Description |
|---|---|---|
| approximate | boolean | Percentiles are a count-weighted mean across indexes when more than one contributed. |
| articles | integer | Articles in the bucket carrying the signal. |
| mean | number | Mean intensity; null when no article carries the signal. |
| percentiles | object | Intensity at each percentile, keyed by the percentile (`50`, `95`, and the requested one). |
In a series, a partial answer still has every bucket. The ones that fell in the part of the index that could not
be read are low, not missing, so a dip in a series with partial: true is not a quiet day. Retry before
plotting it.
Example
# Fourteen days of port congestion coverage: articles, events and outlets per day.
curl -s -G "https://api.unzoi.com/aggregate" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=port congestion" \
--data-urlencode "from=2026-08-18" \
--data-urlencode "to=2026-08-31" \
--data-urlencode "interval=day" \
--data-urlencode "metrics=stories,outlets" \
| jq -r '.buckets[] | "\(.from[0:8]) \(.articles)\t\(.stories)\t\(.outlets)\(if .approximate then " ~" else "" end)"' {
"interval": "day",
"from": "20260818000000",
"to": "20260901000000",
"metrics": ["articles", "stories", "outlets"],
"buckets": [
{ "from": "20260818000000", "to": "20260819000000", "articles": 41, "stories": 12, "outlets": 30, "approximate": false },
{ "from": "20260819000000", "to": "20260820000000", "articles": 187, "stories": 19, "outlets": 96, "approximate": false }
],
"partial": false,
"coverage": "complete",
"history_days": 365,
"from_clamped": false,
"attribution": "Data derived from the GDELT Project (https://www.gdeltproject.org/)."
}
Fourteen buckets, one request. A day where articles jumps and stories does not is
syndication: one event, republished. A day where both jump is news.
A signal alongside the counts:
curl -s -G "https://api.unzoi.com/aggregate" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "organization=maersk" \
--data-urlencode "from=2026-06-01" \
--data-urlencode "interval=week" \
--data-urlencode "metrics=stories,signal:supply_disruption" \
--data-urlencode "percentile=99" Errors
| Status | Meaning |
|---|---|
| 200 | Success |
| 400 | Invalid request, charged nothing. Bounds, enums, dates and offsets are validated BEFORE any index work, so this never means a partially-served query. `credit_budget_exceeded`: the call's worst case is more than `max_credits`. |
| 401 | Missing or invalid API key. `code` is `unauthorized`. |
| 402 | Account suspended (subscription canceled or payment failed). `code` is `suspended`. |
| 429 | Refused before running, and charged nothing. `credit_rate_limited`: this minute's credits are spent. `message_rate_limited`: too many free calls or refused requests this minute. `insufficient_credits`: the key stops at its monthly allocation and does not have this call's worst case left. `retry-after` says how long to wait. |
| 503 | The index could not be read for this request. This is NOT an empty result set and NOT a statement that nothing matched — retry rather than caching it as a negative. `coverage` says which part was unreadable. |
400 with code: invalid_parameter covers the refusals particular to this endpoint: no
interval, more than 366 buckets, a metric that is not one of the four forms, and no
from on a session without a plan. A signal:<name> metric naming a signal that
does not exist is code: unknown_signal, as it is in signal.
Over MCP
The same request is the aggregate_news tool, with the same
arguments and interval required. It answers "is this growing?" and "when did this start?" in one call,
where an agent would otherwise search once per day and count the results itself.
{
"name": "aggregate_news",
"arguments": {
"q": "port congestion",
"from": "2026-08-18",
"to": "2026-08-31",
"interval": "day",
"metrics": "stories,outlets"
}
}