# Universal news search (/docs/search/news)



`GET /v1/search/news` [#get-v1searchnews]

Send one query and get back a planned, localized, multi-country news search in a single call. An LLM plans your query into search angles and localizes each angle into the language of every requested country edition (one combined call, fail-open), the lane fans out up to 12 parallel legs against Google News (50 supported country editions), and the articles merge into one deduplicated list. Every leg reports truthful provenance: `query_source` tells you whether its keyword was `translated`, confirmed `original`, or a `fallback_original` after a localization failure. Returns one JSON envelope by default, or a live SSE stream when you ask for one.

```bash
curl 'https://www.socialcrawl.dev/v1/search/news?query=samsung+galaxy+launch&countries=KR,US,JP' \
  -H 'x-api-key: sc_...'
```

**Credit cost:** metered, **2-14 credits** per call: a 2-credit base covers planning and localization, then 1 credit per leg that returned at least one article. The upfront hold is `2 + min(5 x countries, max_legs, 12)` and the unused remainder is refunded automatically, so the `credits_used` you see in the response is what you actually paid. See [Billing](#billing) and [Endpoint pricing](/docs/endpoint-pricing).

**Auth:** `x-api-key` header, same as every other endpoint.

**Method:** `GET`, all inputs as query params.

***

Request parameters [#request-parameters]

| Param        | Required | Type                                 | Default | Description                                                                                                                                                            |
| ------------ | -------- | ------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `query`      | yes      | string (1 to 500 chars)              | none    | News topic or question. Boolean AND/OR/NOT and quoted phrases are supported; Google advanced operators (`site:`, `intitle:`, `before:`) are rejected with a 400.       |
| `countries`  | no       | CSV of ISO alpha-2 codes             | `US`    | 1 to 12 of the 50 supported country editions (e.g. `KR,US,JP`). Each country searches its Google News edition in that edition's default language; duplicates collapse. |
| `time_range` | no       | `day` \| `week` \| `month` \| `year` | `day`   | Recency window. Ignored when `from`/`to` is present (explicit dates win).                                                                                              |
| `from`       | no       | `YYYY-MM-DD` or Unix                 | none    | Lower publish-date bound. Pins every leg to the primary news source (see below).                                                                                       |
| `to`         | no       | `YYYY-MM-DD` or Unix                 | none    | Upper publish-date bound. Pins every leg to the primary news source.                                                                                                   |
| `publisher`  | no       | bare domain                          | none    | Publisher filter, e.g. `bbc.com` (no scheme, no path). Pins every leg to the primary news source.                                                                      |
| `depth`      | no       | integer 10-100, steps of 10          | `10`    | Articles requested per leg.                                                                                                                                            |
| `max_legs`   | no       | integer 1-12                         | `12`    | Hard cap on billable legs. The upfront hold shrinks with it, so it is your cost-control knob.                                                                          |

`publisher`, `from`, and `to` pin every leg to the primary Google News source: the fallback source cannot express those filters, and silently dropping a filter would return unfiltered data. Pinned requests trade away the fallback's empty-result rescue for filter correctness.

***

How a request becomes legs [#how-a-request-becomes-legs]

1. **Plan.** One combined LLM call turns your query into up to 5 weighted search angles and localizes each angle into the default language of every requested country. The call is fail-open: on timeout or error the lane degrades to a single identity angle (your query, clipped to a keyword) and every leg is stamped `fallback_original`, never silently.
2. **Expand.** Angles cross with countries into legs, capped at `min(5 x countries, max_legs, 12)`. Identical (country, language, keyword) legs collapse before dispatch, so two angles that localize to the same keyword never bill twice.
3. **Fan out.** Every leg runs in parallel as an in-process `google_news/search` call (primary source with automatic fallback and empty-result rescue, unless the request is pinned).
4. **Assemble.** Articles dedupe across legs on `canonical_url ?? url`; the first leg's copy wins the body and every later leg that returned the same article is appended to that item's `legs_returned` provenance list.

The `query_source` vocabulary on every leg is part of the public contract:

* `translated`: the localizer produced a different keyword for that language.
* `original`: the localizer confirmed the planned keyword is already correct for that language. This value is never stamped on a failure path.
* `fallback_original`: localization was unavailable for that language (call failed, timed out, or the language was missing from the result), so the leg searched the planned keyword as-is. A total localizer outage is therefore always distinguishable from "keyword already correct".

***

What you get back (JSON) [#what-you-get-back-json]

By default (`Accept: application/json`) you get one envelope. `data` carries:

| Field                 | Type   | Description                                                                                        |
| --------------------- | ------ | -------------------------------------------------------------------------------------------------- |
| `query`               | string | Your query as parsed.                                                                              |
| `status`              | string | `succeeded` (every leg returned), `partial` (some legs failed), or `failed` (every leg failed).    |
| `plan`                | object | The resolved plan: `topic`, `source` (`llm` or `identity`), `degraded`, `subqueries[]`, `notes[]`. |
| `legs`                | array  | One truthful outcome row per dispatched leg (below).                                               |
| `items`               | array  | Deduplicated articles across all legs, each with `legs_returned` provenance.                       |
| `total_articles`      | number | `items.length`.                                                                                    |
| `countries_searched`  | array  | The resolved country codes, first-seen order.                                                      |
| `credits_used`        | number | The net charge for this call (base + productive legs).                                             |
| `methodology_version` | string | `search-news/1.0`.                                                                                 |

Each `legs[]` row: `leg_id` (`angleId|COUNTRY`), `country`, `language`, `angle_id`, `angle_label`, `effective_query` (the keyword actually searched), `query_source`, `status` (`succeeded` / `failed` / `skipped`), `article_count`, `quarantined_count` (rows dropped as schema-invalid or for having no usable http(s) URL), `credits_used` (1 only when the leg succeeded with at least one article, else 0), `latency_ms`, `error`.

Each `items[]` article: `id` (upstream article id, `null` when the source omits one), `title`, `url` (always http(s)), `canonical_url` (lane-derived: fragment dropped, tracking params stripped, host lowercased), `source_name`, `domain`, `snippet`, `image_url`, `published_at` (UTC ISO or `null`, never fabricated), `rank`, `placement`, plus the provenance stamps from the leg that found it (`country_code`, `language_code`, `angle_id`, `effective_query`, `query_source`) and `legs_returned` (every leg that returned it).

* **Zero articles with succeeded legs is a 200 success** with an empty `items` and base-only billing, not an error.
* **All legs failed is a `502 ALL_LEGS_FAILED`** with `ok: false`; the full hold is refunded automatically. The 502 body is the standard error envelope on its own: per-leg detail (`legs`, `plan`) is not returned on this path, so ask for the SSE stream when you need to see which legs failed and why.

***

Streaming (SSE) [#streaming-sse]

Add `Accept: text/event-stream` and you get a live stream instead of one envelope. Chunk types in order:

```
{ "type": "meta",           "query": "...", "plan": {...}, "sources_planned": ["KR", "US", ...] }
{ "type": "plan_refined",   "plan": {...}, "legs": [{ "leg_id": "sq0|KR", "country": "KR", "language": "ko", "keyword": "...", "query_source": "translated", "angle_id": "sq0", "angle_label": "..." }] }
{ "type": "source_started", "source": "sq0|KR" }
{ "type": "items",          "source": "sq0|KR", "items": [...], "duration_ms": 1234 }
{ "type": "source_failed",  "source": "sq1|US", "error": { "code": "...", "message": "..." } }
{ "type": "done",           "summary": { "credits_used": 4, "refunded": true, "coverage": 1, ... } }
```

Order and guarantees:

* **`meta` is first**, carrying the deterministic identity plan before the LLM call resolves.
* **`plan_refined` is always emitted on this lane** (unlike `/v1/search/everywhere`, where it is conditional on the LLM changing the plan). It carries the final plan plus the expanded `legs[]` with per-leg `query_source`, so a consumer can render localization honesty (translated vs fallback chips) at plan time, before any leg settles. A degraded run still emits it, with every leg stamped `fallback_original`.
* **Legs are keyed as `source` strings** in the form `angleId|COUNTRY` (e.g. `sq0|KR`; angle ids are positional with an `sq` prefix). These are per-request keys, not the fixed source ids of `/v1/search/everywhere`; the everywhere client reducer is not suitable for news streams. Skipped legs (aborted before dispatch) surface as `source_failed` with `error.code: "aborted"`.
* **`items` yield in completion order**, so fast editions land first. The deduplicated cross-leg view is only available in sync mode or by deduplicating client-side on `canonical_url ?? url`.
* **`done` is always last** with the run summary: `credits_used` (net, equals the `X-Credits-Used` header), `refunded`, `coverage`, `sources_called` / `sources_succeeded` / `sources_failed`, `total_items`, `partial_failure`.

Billing on a stream is charge-for-delivered: if you disconnect mid-stream you pay the base plus the productive legs whose `items` chunks you received; the rest of the hold is refunded. On an internal error the whole hold is refunded and the terminal `done` reports `credits_used: 0`, `refunded: true`.

Streaming responses are never cached and never touch the sync cache. They are also not replayable with an `Idempotency-Key`: replay applies to sync JSON responses only, and an SSE-accept request that reuses a spent key will receive the stored JSON body back instead of a stream.

***

Billing [#billing]

Metered, **2-14 credits**: `2` base plus `1` per productive leg, held upfront at `2 + min(5 x countries, max_legs, 12)` and settled down to actual after the run.

| Scenario                               | Charge                                            |
| -------------------------------------- | ------------------------------------------------- |
| 3 countries, all legs return articles  | 2 + productive legs (up to 12 with `max_legs=12`) |
| Zero articles anywhere, legs succeeded | 2 (base only), 200 response                       |
| Every leg failed                       | 0 (full automatic refund), `502 ALL_LEGS_FAILED`  |
| Cache hit (sync, within 5 minutes)     | 0, `X-Cache: HIT`                                 |
| Idempotency-Key replay (sync)          | 0, `X-Idempotent-Replay: true`                    |

`X-Credits-Used` on the response (and `done.summary.credits_used` on a stream) is the source of truth for what you paid. Use `max_legs` to cap the worst case: `max_legs=3` caps the hold at 5 credits regardless of how many countries you request.

***

Latency and failure modes [#latency-and-failure-modes]

The lane plans before it fans out, so the combined plan+localize call is on the critical path. Its budget scales with the number of distinct languages (8s base + 0.7s per extra language, capped at 18s); on timeout the run degrades to the identity plan with every leg stamped `fallback_original` and a note in `plan.notes`, it does not fail. Legs then run fully in parallel, each bounded by the remaining route budget. Typical end-to-end: a few seconds for one country, 10 to 30s for a wide multi-language fan-out.

* **One leg times out or errors:** its row reports `status: "failed"` with an error class; sibling legs continue; the leg bills 0.
* **The localizer drops a language** (operator-laden, overlong, or script-mismatched keyword): that leg searches the planned keyword stamped `fallback_original`.
* **Client disconnect (SSE):** work stops, undelivered legs are refunded, the charge is settled exactly once.

Supported countries: the 50 editions in the lane's catalog (up to date list in the error message of an unknown `countries` code). Notable exclusions: Russia and Lebanon are not servable by the primary news source and are not in the catalog.

***

Related [#related]

* [`GET /v1/search/everywhere`](/docs/search/everywhere): the universal social search sibling.
* [`GET /v1/google_news/search`](/docs/google_news): the single-edition news search each leg rides on.
* [Credits](/docs/credits) and [Endpoint pricing](/docs/endpoint-pricing).
