Skip to content
unzoi docs
Search and navigation
Start here
REST API
MCP
Limits and plans
Agent clients
SDKs
Guides

GET /aggregate

GET https://api.unzoi.com/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.

Filters
sourcesource_typepublisher_countrylanguagestory_idtopicorganizationpersoncountryauthorlocationlocation_idcityregionnamementioned_datequote_verbamount_objectestimatemax_credits
Entity ids
entity_id
Event classes
event_type
Signals
signalsignal_minsignal_maxsignals

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: distinct story_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 /stories gives.
  • 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"
  }
}