---
title: "Get story clusters"
method: POST
path: "/get_story_clusters"
---

# Get story clusters

`POST /get_story_clusters`

## Request

- Base URL: `https://newsmind.app/api/v1`
- URL: `https://newsmind.app/api/v1/get_story_clusters`
- Auth: OAuth 2 (authorization code, authorization URL `https://newsmind.app/oauth/authorize`, token URL `https://newsmind.app/oauth/token`, scopes: `mcp`)

Group redundant coverage into clusters. match=semantic (Max only; Max default) groups one story across outlets by meaning; match=exact (default elsewhere) groups one item syndicated into several feeds by URL/title; both merges the two. Returns canonical article plus also_covered_by.

## Request body

- object
  - `match` 'semantic' | 'exact' | 'both': How to group: 'semantic' = same story by embedding similarity (Max only; default on Max); 'exact' = same item by canonical URL / near-identical title (default on other plans); 'both' = exact then semantic, de-overlapped (Max only).
  - `since_hours` number: Look-back window in hours. Default 48 for semantic, 168 (7 days) for exact. Capped by tier retention.
  - `min_cluster_size` integer: Minimum articles per cluster (default 2).
  - `limit` integer: Max clusters to return (default 20). Values outside 1-50 are clamped.
  - `unread_only` boolean: If true, only cluster unread articles. Default false. Semantic matching only — ignored by 'exact'.
  - `match_by` 'url' | 'title' | 'either': Exact-matching strictness, used only when match is 'exact' or 'both'. url = same canonical URL only (strictest); title = same lowercased title; either = url-OR-title.

## Response `200`

Success

- object
  - `count` integer
  - `window_hours` integer
  - `match` 'semantic' | 'exact' | 'both'
  - `match_by` string: Present only when exact matching ran.
  - `clusters` object[]
    - `cluster_id` integer
    - `match` 'semantic' | 'url' | 'title': Which strategy produced this cluster.
    - `key` string, nullable: The matched URL or title for exact clusters; null for semantic.
    - `size` integer
    - `feeds` integer: Number of distinct feeds the cluster spans.
    - `canonical` Article
      - `id` integer
      - `feed_id` integer: ID of the feed this article belongs to. Useful for chaining into list_feeds or unsubscribe.
      - `feed_title` string, nullable
      - `title` string, nullable
      - `url` string, nullable
      - `image_url` string, uri, nullable: Thumbnail URL for the article, when available. HTTPS-only; tracking pixels filtered out. Sourced from <enclosure>, <media:thumbnail>, <media:content>, or first <img> in content. Forward-only — articles ingested before 0.3.0 have null.
      - `image_width` integer, nullable: Pixel width of image_url, when the source feed exposed it (MRSS attrs or HTML width=). Lets clients lay out before fetching the bytes. Null when unknown.
      - `image_height` integer, nullable: Pixel height of image_url, when known. Null otherwise.
      - `image_mime` string, nullable: MIME type of image_url (image/jpeg, image/png, image/webp, image/gif, image/avif, image/svg+xml). Sourced from <enclosure type=>, <media:content type=>, or inferred from URL extension. Null when neither available.
      - `summary` string, nullable
      - `published_at` string, date-time, nullable: When the source published the article (from the RSS feed).
      - `added_at` string, date-time, nullable: When Newsmind ingested the article into the user's feed. Use this when the user asks about 'when did this show up for me' or 'what's new since I last checked'; use published_at for 'how old is the story'.
      - `matched_watches` object[]: Saved watches this article matched. Present on unread/new-article responses when the user has active watches.
        - `watch_id` integer
        - `label` string, nullable
      - `likely_archival` object: Present when the title states a date a year or more before published_at — an aggregator re-indexed an old page under today's date. Don't present these as new.
        - `title_year` integer
        - `matched_text` string
        - `pattern` 'slash' | 'written' | 'paren'
      - `also_in` object[]: Other feeds that carried this same story. The duplicate was collapsed to one match (issue #59).
        - `feed_id` integer
        - `feed_title` string, nullable
      - `user_excluded` object: This row matched one of the watch's exclude_terms. Demoted, never dropped, and the term is named so an over-broad guess can be corrected (issue #23).
        - `term` string
      - `low_signal` object: Present when a Google News item's source is an encyclopedia, film database, statistics page or obituary listing rather than reporting. `rule: "obituary"` comes from a headline rule and may name a legitimate newsroom — describe the item, not the outlet.
        - `source` string
        - `rule` 'reference' | 'entertainment_db' | 'sports_stats' | 'obituary' | 'archive_page' | 'scraped_video'
    - `also_covered_by` Article[]
      - `id` integer
      - `feed_id` integer: ID of the feed this article belongs to. Useful for chaining into list_feeds or unsubscribe.
      - `feed_title` string, nullable
      - `title` string, nullable
      - `url` string, nullable
      - `image_url` string, uri, nullable: Thumbnail URL for the article, when available. HTTPS-only; tracking pixels filtered out. Sourced from <enclosure>, <media:thumbnail>, <media:content>, or first <img> in content. Forward-only — articles ingested before 0.3.0 have null.
      - `image_width` integer, nullable: Pixel width of image_url, when the source feed exposed it (MRSS attrs or HTML width=). Lets clients lay out before fetching the bytes. Null when unknown.
      - `image_height` integer, nullable: Pixel height of image_url, when known. Null otherwise.
      - `image_mime` string, nullable: MIME type of image_url (image/jpeg, image/png, image/webp, image/gif, image/avif, image/svg+xml). Sourced from <enclosure type=>, <media:content type=>, or inferred from URL extension. Null when neither available.
      - `summary` string, nullable
      - `published_at` string, date-time, nullable: When the source published the article (from the RSS feed).
      - `added_at` string, date-time, nullable: When Newsmind ingested the article into the user's feed. Use this when the user asks about 'when did this show up for me' or 'what's new since I last checked'; use published_at for 'how old is the story'.
      - `matched_watches` object[]: Saved watches this article matched. Present on unread/new-article responses when the user has active watches.
        - `watch_id` integer
        - `label` string, nullable
      - `likely_archival` object: Present when the title states a date a year or more before published_at — an aggregator re-indexed an old page under today's date. Don't present these as new.
        - `title_year` integer
        - `matched_text` string
        - `pattern` 'slash' | 'written' | 'paren'
      - `also_in` object[]: Other feeds that carried this same story. The duplicate was collapsed to one match (issue #59).
        - `feed_id` integer
        - `feed_title` string, nullable
      - `user_excluded` object: This row matched one of the watch's exclude_terms. Demoted, never dropped, and the term is named so an over-broad guess can be corrected (issue #23).
        - `term` string
      - `low_signal` object: Present when a Google News item's source is an encyclopedia, film database, statistics page or obituary listing rather than reporting. `rule: "obituary"` comes from a headline rule and may name a legitimate newsroom — describe the item, not the outlet.
        - `source` string
        - `rule` 'reference' | 'entertainment_db' | 'sports_stats' | 'obituary' | 'archive_page' | 'scraped_video'

## Other responses

- `400`: Bad request
- `401`: Unauthorized
- `402`: Subscription required (trial expired or cancelled)
- `429`: Too many calls. Either the per-account rate limit ({error: "rate_limited", message}) or the account's in-flight work ({error: <sentence>, code: "busy"}). Wait Retry-After seconds.
- `500`: Server error on our side (error: internal_error). The team has been notified.
- `503`: Temporarily unable to answer. error: timeout (the request ran too long; narrow it), server_busy or tier_unavailable (retry after Retry-After seconds when sent).

---

[API](https://skmtc.dev/newsmind/apis/newsmind-api.md) · [All operations](https://skmtc.dev/newsmind/apis/newsmind-api/llms.txt) · [OpenAPI document](https://skmtc.dev/newsmind/apis/newsmind-api/revisions/f8af23fb5a10?raw)
