Evaluating coverage
"Was this covered?" is a different question from "what happened?", and it is answered with counts rather than with articles.
Volume is not attention
A search returning 400 articles tells you almost nothing. It could be 400 outlets independently covering something,
or it could be one press release republished 400 times. /stories separates
them:
curl -s -G "https://api.unzoi.com/stories" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=battery plant announcement" \
--data-urlencode "from=2026-08-01" \
--data-urlencode "limit=20" \
| jq -r '.stories[] | "\(.outlets)\t\(.count)\t\(.title)"' | outlets | count | Reading |
|---|---|---|
| 35 | 41 | Widely distributed. Check sources and title_variants before calling it independent. |
| 3 | 40 | One wire story or press release, syndicated. High volume, no independent interest. |
| 2 | 2 | Early or niche. Check again tomorrow before drawing a conclusion. |
The outlets/count ratio is the most useful single number this API gives you for judging
attention, and a plain article search discards it entirely. It measures distribution, not newsrooms:
outlets counts distinct publisher domains, and a wire service or an ownership group can put one
newsroom's report on many of them. Read sources — the domains themselves — before calling 35 outlets
independent coverage, and title_variants on /stories/{id}:
35 outlets carrying two distinct headlines is one or two reports, redistributed.
Who covered it
Facets count over the whole match set, not the page — so one request tells you the shape of the coverage:
curl -s -G "https://api.unzoi.com/search" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=battery plant announcement" \
--data-urlencode "from=2026-08-01" \
--data-urlencode "facets=source,publisher_country,language" \
--data-urlencode "facet_limit=20" \
--data-urlencode "limit=1" \
| jq '.facets | map_values([.values[] | "\(.value): \(.count)"])' limit=1 because you want the counts, not the articles. One billed request either way, and this way you
are not paying context for results you will discard.
Coverage over time
/aggregate answers it in one request: the same query and filters, cut
into calendar days, with articles, distinct stories and distinct outlets counted in each.
curl -s -G "https://api.unzoi.com/aggregate" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=port congestion" \
--data-urlencode "from=$(date -u -d '13 days ago' +%Y-%m-%d)" \
--data-urlencode "interval=day" \
--data-urlencode "metrics=stories,outlets" \
| jq -r '.buckets[] | "\(.from[0:8]) \([range(.stories) | "#"] | join(""))"'
Fourteen buckets, one request. Plot stories rather than articles: an article series
inflates the same event, and it is events you are plotting. Check approximate on a bucket before
quoting its figure: a distinct count summed across index partitions can count one story twice.
When a count is approximate.
The portable way, which needs nothing but /stories, is one request per window:
for day in $(seq 13 -1 0); do
d=$(date -u -d "$day days ago" +%Y-%m-%d)
n=$(curl -s -G "https://api.unzoi.com/stories" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=port congestion" \
--data-urlencode "from=$d" --data-urlencode "to=$d" \
--data-urlencode "limit=1" | jq -r '.total')
printf '%s %s\n' "$d" "$(printf '%*s' "$n" '' | tr ' ' '#')"
done
Fourteen requests for the same fortnight, each small enough to be fast and likely to succeed. Worth keeping for a
client that already speaks /stories, and as a cross-check when a bucket says
approximate.
Did the framing differ?
This is the question clustering cannot answer — you need the articles, split by who published them.
/stories/{id} gives the story's languages and publisher countries
as counts in one request; when you want the articles behind them, expand the cluster and facet it:
# The articles in one story, by publisher country.
curl -s -G "https://api.unzoi.com/search" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "story_id=s-9f2c" \
--data-urlencode "facets=publisher_country,language" \
--data-urlencode "limit=50"
Then compare titles across the groups. The signal values on each article give
you a second axis — the same event covered with markedly different financial_uncertainty or
conflict intensity is a framing difference you can point at rather than assert.
What you cannot conclude
- Absence of coverage, from an incomplete answer. A response with
partial: trueor acoverageother thancompletedid not read the whole index, and a short result under it is not a small story. Zero results may also be a wrong filter value (resolve it with/entities), a plan-clamped window, or a language you did not include. Check all three fields, and re-run without filters, before saying "nobody covered this" — Reading completeness. - Reach.
outletscounts publishers, not readers. A story in three major nationals may reach more people than one in thirty local sites. - Sentiment. Signals measure register, not approval. A high
financial_negativemeans the language is that of financial distress, not that the article disapproves. - Totals from a ranked query.
total_relation: approximatemeans the number is a candidate count. Use a keyword query if the figure needs to be defensible.
A repeatable check
# One question, three numbers and a yes/no: events, articles, distinct outlets,
# and whether the index actually answered.
q="grid outage"; from="2026-08-01"
stories=$(curl -s -G "https://api.unzoi.com/stories" -H "x-api-key: $UNZOI_KEY" \
--data-urlencode "q=$q" --data-urlencode "from=$from" --data-urlencode "limit=100")
echo "events: $(echo "$stories" | jq -r '.total')"
echo "articles: $(echo "$stories" | jq '[.stories[].count] | add')"
echo "outlets: $(echo "$stories" | jq '[.stories[].sources[]] | unique | length')"
echo "complete: $(echo "$stories" | jq '(.partial | not) and .coverage == "complete" and (.from_clamped | not)')"
Three numbers and a boolean, one request. Enough to say whether something was covered and how widely — and, when
complete is false, that the other three are a lower bound rather than an answer.