GET /stories/{id}
One story, expanded.
Everything /stories collapsed, as one object: the article and outlet counts, every outlet, when the story was first and last seen, the entities and topics it is about, and its newest articles as compact hits -- computed at request time, so it is current at the moment you ask. Equivalent to the `get_story` MCP tool. For faceting or paging the articles use /search?story_id=. Credits: 2 for the whole story, however many articles it lists. Pass estimate=true to get the exact range without running the call, or max_credits to refuse anything costlier.
-
id— The story id, from /stories or an article's story_id.
What /stories collapsed, opened back up. One request returns the cluster's
counts, its outlets, when it was first and last seen and who had it first, the organizations, people and topics
across its members, the representative article, and its newest members. Nothing is precomputed: it is built at request time from the
cluster's member articles, so it is as current as the index.
Parameters
| Name | Type | Description |
|---|---|---|
| limit | integer | Articles to list, 1-100 (default 20). `articles_truncated` says whether more exist; page them with /search?story_id=. |
| from | string | Lower time bound, e.g. 2026-07-09 or 20260709120000. Clamped forward to what the caller's plan may reach; see `history_days` and `from_clamped` in the response. |
| to | string | Upper time bound, same formats as `from`. |
| estimate | boolean | Return this call's price instead of running it: the credit range, a breakdown, your remaining credits and whether the call would run. A preview costs 0 credits. |
| max_credits | integer | Refuse the call, at no cost, when its worst case would cost more than this many credits. The refusal carries the estimate. |
limit caps the articles listed, not the counts: count and outlets are the
cluster's whatever you ask for. There is no offset. To page the members, or to facet or filter them,
use /search?story_id= — below.
from and to bound which members are read. The articles listed, the timestamps and the
facet lists come from members inside the window, so a from clamped by your plan
(from_clamped: true) leaves older members unlisted.
Response
| Field | Type | Description |
|---|---|---|
| articles | CompactArticle[] | The newest members, newest first, as compact hits. |
| articles_returned | integer | How many articles are listed. |
| articles_truncated | boolean | Whether the cluster has more members than were listed; page them with /search?story_id=. |
| attribution | string | Required source attribution for any use of these results. |
| count | integer | Articles in the cluster. |
| 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. |
| first_reported | FirstReport | Who had it first: the earliest member in the index. A proxy for origin, not lineage -- wire copy is not marked. |
| first_seen | string | Earliest `published_at` among the listed articles, 14-digit YYYYMMDDHHMMSS; never later than the id's own stamp. |
| from_clamped | boolean | Whether the window was moved forward to the plan's boundary; older members are then not listed. |
| history_days | integer | How far back the caller's plan may query; null = the full archive. |
| languages | FacetValue[] | Languages of the listed articles, with counts. |
| last_seen | string | Latest `published_at` among the listed articles. Compare with `first_seen` for how long the story has been running. |
| outlets | integer | Distinct publisher domains in the cluster. Domains, not newsrooms: read `sources` before calling coverage independent. |
| 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. |
| publisher_countries | FacetValue[] | Publisher countries of the listed articles, with counts. |
| representative_article_id | string | The article `title` and `url` came from; pass to /doc/{id}. |
| sources | string[] | Publisher domains in the cluster, capped; `outlets` is the full count. |
| sources_by_first_seen | SourceFirstSeen[] | Each listed outlet with the earliest time it carried the story, earliest first. |
| story_id | string | The cluster id. |
| title | string | Headline of the representative article. |
| title_variants | integer | Distinct normalised headlines among the listed articles: a proxy for how many separate write-ups exist rather than copies of one. |
| top_organizations | FacetValue[] | Organizations mentioned across the listed articles, most frequent first. |
| top_persons | FacetValue[] | People mentioned across the listed articles, most frequent first. |
| top_topics | FacetValue[] | Topics across the listed articles, most frequent first. |
| url | string | URL of the representative article. |
Article
The articles are always compact hits — the fields view=compact gives on
/search — because the point of this endpoint is to hand a
model enough to decide what to read next, not everything at once. Fetch one in full with
/doc/{id}.
| Field | Type | Description |
|---|---|---|
| id | string | Stable article id; pass to /doc/{id} and /similar/{id}. |
| language | string | Source language, ISO-639-3. |
| published_at | string | 14-digit YYYYMMDDHHMMSS, UTC. |
| score | number | Ranking score of this hit: BM25 for keyword, fused similarity for semantic, reciprocal-rank fusion for hybrid. Comparable within one response only; absent on a recency browse. |
| source | string | Publisher domain, e.g. bbc.co.uk. |
| story_id | string | The cluster this article belongs to. Pass to /stories/{id} to expand the story, or back as the `story_id` filter. |
| title | string | Headline as published. |
| url | string | Canonical article URL. Link this; the publisher gets the visit. |
Reading it
-
first_seen,last_seen— when the story began, and whether it is still running.first_seenis never later than the id's own stamp: a story id begins with the 14-digit collection time of the article that seeded the cluster. Alast_seenthat keeps moving is a story still acquiring coverage; one that stopped a day ago is usually over. -
outletsversussources—outletsis the number of distinct publisher domains;sourcesis the list, capped. Domains, not newsrooms: a wire service or an ownership group puts one report on many domains. Readsourcesbefore calling coverage independent. -
representative_article_id— the articletitleandurlcame from. Pass it to/doc/{id}for the full record. -
top_organizations,top_persons,top_topics— counts over the listed members, most frequent first: what the story is about, in the index's own vocabulary. Every value is a valid filter value as it stands. -
articles_returned,articles_truncated— how many were listed, and whether the cluster holds more.truemeans page them with/search?story_id=&offset=.
Who had it first
Three fields describe where a story came from, as far as the index can see.
-
first_reported— the earliest member the index holds in the window: itsarticle_id,sourceandpublished_at. Who had it first, among the outlets the index reads. -
title_variants— how many distinct headlines the listed articles carry, once normalised. Close toarticles_returnedmeans many separate write-ups; close to 1 means one piece, copied. -
sources_by_first_seen— each listed outlet with the earliest time it carried the story, earliest first. The order the story spread in.
title_variants and sources_by_first_seen are computed over the listed articles, so they
depend on limit. At the default they describe the newest 20 members, and for a larger story an
outlet's "first seen" there is its first appearance among those 20. Raise limit, and compare
articles_returned with count, before reading them as the whole story.
Versus /search?story_id=
Both expand a story. They answer different questions.
| You want | Use |
|---|---|
| The story as one object: counts, outlets, when, what it is about, the newest articles | This endpoint. One request. |
| Every member, paged | /search?story_id=&offset= |
| Members counted by publisher country, language, outlet | /search?story_id=&facets= |
| Members filtered: one language, one outlet, a signal range | /search?story_id=&language= |
| Full article records | /doc/{id}, one per article |
The rule of thumb: this endpoint to understand a story, /search when its members are the point.
Articles that were never clustered
An article the clusterer left alone carries its own id as its story_id. Expanding that id
returns a one-article story: count 1, one outlet, first_seen equal to
last_seen. That is the answer, not an error, and it is what most single-outlet coverage looks like.
Here partial: true means one of the index partitions holding the cluster's members did not answer, so
count, the facets and the articles are from the ones that did. Retry before reading a low count as a
small story.
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`. |
| 404 | No document with that id. Distinct from 503: this one IS a statement of absence and is safe to cache. |
| 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. |
404 and 503 are different statements. 404 (code: not_found)
means every partition that could hold the story said it is not there; it is safe to cache. 503
(upstream_unavailable) means one of them could not be asked. Retry it, and do not cache it as absence.
Example
curl -s "https://api.unzoi.com/stories/s-9f2c?limit=3" -H "x-api-key: $UNZOI_KEY" {
"story_id": "s-9f2c",
"count": 23,
"outlets": 19,
"sources": ["reuters.com", "bbc.co.uk", "ft.com", "handelsblatt.com"],
"title": "New export controls tighten chip supply",
"url": "https://example.com/chips",
"first_seen": "20260826073000",
"last_seen": "20260827161500",
"first_reported": {
"article_id": "20260826073000-a1b2c3",
"source": "reuters.com",
"published_at": "20260826073000"
},
"languages": [{ "value": "eng", "count": 17 }, { "value": "deu", "count": 3 }],
"publisher_countries": [{ "value": "US", "count": 8 }, { "value": "GB", "count": 5 }],
"top_organizations": [{ "value": "asml", "count": 21 }],
"top_persons": [],
"top_topics": [{ "value": "ECON_TRADE", "count": 23 }],
"title_variants": 2,
"sources_by_first_seen": [
{ "source": "bbc.co.uk", "first_seen": "20260827094500" },
{ "source": "handelsblatt.com", "first_seen": "20260827120000" },
{ "source": "ft.com", "first_seen": "20260827161500" }
],
"representative_article_id": "20260826073000-a1b2c3",
"articles": [
{
"id": "20260827161500-d4e5f6",
"title": "Chipmakers weigh new licence rules",
"url": "https://example.com/licence-rules",
"source": "ft.com",
"published_at": "20260827161500",
"language": "eng",
"story_id": "s-9f2c"
}
],
"articles_returned": 3,
"articles_truncated": true,
"partial": false,
"coverage": "complete",
"history_days": 365,
"from_clamped": false,
"attribution": "Data derived from the GDELT Project (https://www.gdeltproject.org/)."
} Over MCP
The same request is the get_story tool, with id
and limit. It is the step the agent workflow had to fake with a second search:
list_stories → get_story → get_article → find_related, and no
call re-runs a query. Building a news agent uses it.
{ "name": "get_story", "arguments": { "id": "s-9f2c", "limit": 5 } }