openapi: 3.1.0
info:
  title: News Search API
  version: 1.0.0
  license:
    name: Proprietary
    url: https://newssearchapi.com/legal/terms
  summary: Search and retrieve news articles from publishers worldwide.
  description: |
    The News Search API public REST API (v1).

    ## Conventions
    - All responses are JSON (`application/json; charset=utf-8`). `HEAD` is supported on every
      endpoint (headers only); `OPTIONS` on any path answers a CORS preflight with `204`.
    - Every response carries an `X-Request-Id` header (a 26-character ULID). Error bodies repeat it as `requestId`.
    - Success envelope: `{"status":"ok", ...}`. Error envelope:
      `{"status":"error","code":"<ErrorCode>","message":"<human readable>","requestId":"<ULID>"}`.
    - CORS: `Access-Control-Allow-Origin: *`; preflight allows methods `GET, HEAD, OPTIONS` and headers
      `X-Api-Key, Authorization`. All rate-limit / credit / request-id headers are exposed to browsers.
    - Unknown paths return `404 routeNotFound`; methods other than GET/HEAD/OPTIONS return `405 methodNotAllowed`
      (with an `Allow: GET, HEAD, OPTIONS` header).

    ## Query parameter validation (all endpoints)
    - Parameters not listed for an endpoint are rejected with `400 parameterInvalid` naming the parameter
      (`apiKey` is accepted everywhere). Unknown parameters are checked first, in alphabetical order.
    - A parameter may appear only once; pass multiple values as one comma-separated list.
    - Values are trimmed; an empty value (`?country=`) is treated as if the parameter were absent.
    - Comma-separated lists: empty items (`en,,fr`) are invalid; duplicates are removed.
    - Integers must be plain base-10 digits (no sign, decimals or exponent).
    - Validation errors are reported one at a time (the first failing check).

    ## Authentication, rate limits and credits
    Authenticated endpoints accept keys through either the `X-Api-Key` header or the `apiKey` query parameter.
    Use the header for server-side requests to keep keys out of URLs and logs; the query parameter is convenient
    for browser tests but may be retained by browser history, proxies, or analytics. `Authorization: Bearer` is also
    supported. Keys look like `gna_live_...`.
    API-key metadata is cached for up to 60 seconds. Sharedstack plan entitlements refresh every 5 minutes; while refresh is unavailable, the last successful entitlement snapshot is used and refreshed in the background. New accounts fail closed with 503 when Stack has no cached entitlement; an authoritative missing/inactive plan returns 403.

    Every plan returns article metadata plus an excerpt of about 200 characters in `content`; no plan returns full article text. All plans allow `page_size` up to 100. Article date ranges may cover the available archive; the API does not impose a plan-based history limit. The gateway applies one uniform rate limit; the API has no per-plan rate limit. Credit holds and balances are sharedstack-owned.

    Requests require a valid API key and active plan; article searches reserve credits with Sharedstack before querying. The gateway applies its uniform rate limit independently of plan entitlements.
    One credit equals USD $0.01. Each successful search costs a 1-credit base (including zero-result searches), covers the first 10 returned articles, then costs 0.1 credit per additional article. The first 30 days of search history are included; beyond that, searches cost 0.1 credit per started day. A successful lookup-by-ID costs 1 credit. A successful `/v1/counts` request costs one search base charge, whatever its range or number of queries. Errors (including 4xx/5xx) and non-article endpoints cost nothing. Search requests reserve the maximum possible page charge plus the range surcharge, then settle the actual charge using the request ID for idempotency; unused holds are released. Credit charges are reported to five decimal places. Balances are never cached by News. Article responses expose `X-Credits-Charged` and `X-Credits-Remaining` when Stack returns a balance; an insufficient-credit response may report `unknown` remaining because Stack's AppAuth error does not include balance.
  contact:
    name: News Search API
    url: https://newssearchapi.com/contact
  termsOfService: https://newssearchapi.com/legal/terms
servers:
  - url: https://apis.newssearchapi.com
    description: Production
  - url: https://apis.dev.newssearchapi.com
    description: Development
tags:
  - name: Articles
    description: Search and retrieve articles (API key required).
  - name: Intelligence
    description: News Intelligence aggregates — article volume trends and share of voice (API key required).
  - name: Public
    description: Key-less endpoints used by the website. Not logged and not charged credits.
security:
  - ApiKeyHeader: []
  - BearerAuth: []
  - ApiKeyQuery: []
paths:
  /v1/articles:
    get:
      tags: [Articles]
      operationId: searchArticles
      summary: Search articles
      description: |
        Returns articles matching all supplied filters. `q` supports quoted phrases, AND/OR/NOT and parentheses
        (at most 10 boolean operators); search uses the isx archive keyword index. `search_in` selects title, description,
        and/or content. Content can be searched on every plan; responses return only an excerpt of it.
        When dates are omitted, the search defaults to the last 7 days; this default window is separate from the 30 days of history included in pricing. Ranges may extend across the available archive, with the documented surcharge applying beyond 30 days. A query the archive rejects as too broad returns `400`; narrow the range or add filters.
        Page one includes `totalResults`; later pages omit it. `dedupe=true` keeps the highest-trust article per story.
        Relevance ranks title hits above description and content, then sorts by recency.
      parameters:
        - $ref: '#/components/parameters/Q'
        - name: search_in
          in: query
          description: "Comma-separated fields to search: title, description, content. Content search is available on every plan."
          schema: {type: string}
          example: title,description,content
        - name: domains
          in: query
          description: Include up to 10 publisher domains; matching includes subdomains.
          schema: {type: string}
          example: example.com,news.example.org
        - name: exclude_domains
          in: query
          description: Exclude up to 10 publisher domains; matching includes subdomains.
          schema: {type: string}
          example: spam.example
        - name: region
          in: query
          description: Case-insensitive article region filter (maximum 100 characters).
          schema: {type: string, maxLength: 100}
          example: Europe
        - name: dedupe
          in: query
          description: Keep one article per story, selecting the highest trust score.
          schema: {type: boolean, default: false}
        - name: cursor
          in: query
          description: Opaque nextPageToken from the previous response; continue after exactly the articles returned.
          schema: {type: string, maxLength: 64}
        - $ref: '#/components/parameters/Country'
        - $ref: '#/components/parameters/Language'
        - name: category
          in: query
          description: Comma-separated categories (max 10, each 1–50 characters). Matches articles having any of them (case-insensitive, exact match).
          schema: {type: string}
          example: politics,world
        - name: author
          in: query
          description: Case-insensitive substring match on the article author (max 100 characters).
          schema: {type: string, maxLength: 100}
          example: smith
        - name: from
          in: query
          description: |
            Oldest `publishedAt` to include (inclusive), UTC. Either a date `YYYY-MM-DD` (00:00:00 UTC that day) or a
            datetime: RFC 3339 with `Z` or an offset (URL-encode `+` as `%2B`), optional fractional seconds, or
            `YYYY-MM-DDTHH:MM[:SS]` without offset (interpreted as UTC). Must be earlier than `to`.
          schema: {type: string}
          examples:
            date: {value: '2026-10-01'}
            datetime: {value: '2026-10-01T08:30:00Z'}
        - name: to
          in: query
          description: |
            Newest `publishedAt` to include (inclusive), UTC. Same formats as `from`; a date-only value includes the
            whole day (up to 23:59:59.999999 UTC).
          schema: {type: string}
          example: '2026-10-31'
        - name: indexed_from
          in: query
          description: 'ISO-8601 date or datetime (inclusive). Filters on `created_at`, the time this article version was last indexed. Use with `sort=indexed_desc` and the last seen `indexedAt` for incremental sync; repeat with an overlap window if your pipeline needs retries.'
          schema: {type: string}
          example: '2026-10-31T09:00:00Z'
        - name: sort
          in: query
          description: Relevance (title hit > description hit > content hit, then recency), publication time, or indexed time.
          schema: {type: string, enum: [relevance, published_desc, published_asc, indexed_desc], default: published_desc}
        - name: mode
          in: query
          description: |
            Ranking mode. `lexical` (default) is keyword search over the whole archive. `semantic` ranks by meaning
            (embedding similarity); `hybrid` fuses lexical and semantic ranks (reciprocal rank fusion). Both require `q`,
            apply every other filter, sort by relevance only (`sort` must be omitted or `relevance`), do not support
            `dedupe`, return at most the first 200 results (offset plus `page_size` ≤ 200), and omit `totalResults`.
            Semantic coverage is limited to the most recently indexed article versions; the newest articles may
            not be embedded yet (hybrid still finds them lexically). If the semantic index has not been built or
            its provider is unavailable the request fails with `503 serviceUnavailable`; it never falls back to
            lexical.
          schema: {type: string, enum: [lexical, semantic, hybrid], default: lexical}
          example: hybrid
        - $ref: '#/components/parameters/Page'
        - name: page_size
          in: query
          description: Results per page; maximum 100 for every key. `page × page_size` must be ≤ 10,000.
          schema: {type: integer, minimum: 1, maximum: 100, default: 10}
          example: 10
      responses:
        '200':
          description: A page of articles.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
            X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
            X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
            X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
            X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ArticleList'}
              examples:
                page:
                  $ref: '#/components/examples/ArticleListOk'
        '400': {$ref: '#/components/responses/AuthenticatedBadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402': {$ref: '#/components/responses/InsufficientCredits'}
        '403': {$ref: '#/components/responses/AuthenticatedForbiddenPlan'}
        '405': {$ref: '#/components/responses/MethodNotAllowed'}
        '429': {$ref: '#/components/responses/AuthenticatedTooManyRequests'}
        '500': {$ref: '#/components/responses/AuthenticatedInternalError'}
        '503': {$ref: '#/components/responses/AuthenticatedServiceUnavailable'}
  /v1/articles/{id}:
    get:
      tags: [Articles]
      operationId: getArticle
      summary: Get one article
      description: |
        Returns a single article by the stored `id` returned from `/v1/articles`. IDs are signed 64-bit decimal
        strings (migrated from the URL fingerprint), so they may be negative.
        Accepts no query parameters other than `apiKey`. Any article still available in the data store can be retrieved.
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, pattern: '^-?[0-9]{1,20}$'}
          example: '-4210765838120375291'
      responses:
        '200':
          description: The article.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
            X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
            X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
            X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
            X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
          content:
            application/json:
              schema:
                type: object
                required: [status, article]
                properties:
                  status: {type: string, const: ok}
                  article: {$ref: '#/components/schemas/Article'}
              examples:
                excerpt:
                  $ref: '#/components/examples/ArticleOkTruncated'
        '400': {$ref: '#/components/responses/AuthenticatedBadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402': {$ref: '#/components/responses/InsufficientCredits'}
        '403': {$ref: '#/components/responses/AuthenticatedForbiddenPlan'}
        '404':
          description: No article with this id (`notFound`).
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
            X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
            X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
            X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
            X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example: {status: error, code: notFound, message: "No article found with id '123'.", requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
        '405': {$ref: '#/components/responses/MethodNotAllowed'}
        '429': {$ref: '#/components/responses/AuthenticatedTooManyRequests'}
        '500': {$ref: '#/components/responses/AuthenticatedInternalError'}
        '503': {$ref: '#/components/responses/AuthenticatedServiceUnavailable'}
  /v1/counts:
    get:
      tags: [Intelligence]
      operationId: countArticles
      summary: Count articles over time
      description: |
        News Intelligence: daily or weekly article volume for 1–5 queries over the same filters, with each query's
        top 5 sources, countries and languages, and its share of voice (`total / sum of totals × 100`, one decimal;
        all 0 when nothing matched). Use it for trend charts and to compare topics, brands or people.
        Repeat `q` once per query (the only parameter that may be repeated); each `q` uses the `/v1/articles`
        search syntax and the series are returned in request order. `from`/`to` are inclusive UTC days and default to the
        last 30 days; a range may span at most 366 days. Week buckets are ISO weeks starting Monday.
        A successful request costs one search base charge, regardless of the range or number of queries; errors cost nothing.
        A query the archive rejects as too broad returns `400`; narrow the range or add filters.
      parameters:
        - name: q
          in: query
          required: true
          description: "Search query in `/v1/articles` syntax (max 500 characters). Repeat 1–5 times for one series per query; values must be distinct."
          style: form
          explode: true
          schema: {type: array, minItems: 1, maxItems: 5, items: {type: string, maxLength: 500}}
          example: [electric vehicles]
        - name: from
          in: query
          description: First UTC day to count (inclusive), `YYYY-MM-DD`. Defaults to 29 days before `to`.
          schema: {type: string, format: date}
          example: '2026-10-01'
        - name: to
          in: query
          description: Last UTC day to count (inclusive), `YYYY-MM-DD`. Defaults to today (UTC).
          schema: {type: string, format: date}
          example: '2026-10-31'
        - name: interval
          in: query
          description: Bucket size; `week` buckets are ISO weeks dated by their Monday.
          schema: {type: string, enum: [day, week], default: day}
        - $ref: '#/components/parameters/Country'
        - name: category
          in: query
          description: Comma-separated categories (max 10, each 1–50 characters). Matches articles having any of them (case-insensitive, exact match).
          schema: {type: string}
          example: business,technology
        - $ref: '#/components/parameters/Language'
        - name: source
          in: query
          description: Comma-separated publisher domains (max 10); matching includes subdomains.
          schema: {type: string}
          example: example.com,news.example.org
        - name: minTrust
          in: query
          description: Count only articles with a trust score of at least this value.
          schema: {type: number, minimum: 0, maximum: 1}
          example: 0.6
        - name: dedupe
          in: query
          description: Count each story once.
          schema: {type: boolean, default: false}
      responses:
        '200':
          description: One series per query plus share of voice.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
            X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
            X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
            X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
            X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Counts'}
              examples:
                compare:
                  $ref: '#/components/examples/CountsOk'
        '400':
          description: Invalid parameter, or a query the archive rejects as too broad; no credits are charged.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
            X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example: {status: error, code: parameterInvalid, message: "Parameter 'q' must be given 1 to 5 times, once per query (got 6).", requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '402': {$ref: '#/components/responses/InsufficientCredits'}
        '403': {$ref: '#/components/responses/AuthenticatedForbiddenPlan'}
        '405': {$ref: '#/components/responses/MethodNotAllowed'}
        '429': {$ref: '#/components/responses/AuthenticatedTooManyRequests'}
        '500': {$ref: '#/components/responses/AuthenticatedInternalError'}
        '503': {$ref: '#/components/responses/AuthenticatedServiceUnavailable'}
  /v1/stats:
    get:
      tags: [Public]
      operationId: getStats
      summary: Coverage statistics
      description: |
        Live coverage numbers. No API key, not logged, coverage cached server-side for 10 minutes and ingestion/performance metrics for 60 seconds (if a refresh fails the
        previous value keeps being served). Accepts no query parameters other than `apiKey`.
      security: []
      responses:
        '200':
          description: Coverage statistics.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Stats'}
              example: {status: ok, sources: 412, countries: 57, languages: 31, updatedAt: '2026-11-01T06:00:00Z'}
        '400': {$ref: '#/components/responses/BadRequest'}
        '405': {$ref: '#/components/responses/MethodNotAllowed'}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
  /v1/headlines/sample:
    get:
      tags: [Public]
      operationId: sampleHeadlines
      summary: Sample of latest headlines
      description: |
        Up to 100 most recently published articles with a non-empty title from the last 15 minutes (and not
        in the future), in descending publication order. If fewer than five are available, the response is
        supplemented with the newest older articles needed to reach five, limited to articles published
        within the last 2 hours. `feedStatus: stale` indicates supplementation; if no articles are available
        within the 2-hour maximum age, the response is empty with `feedStatus: empty`. No all-time fallback.
        No API key, not logged,
        cached server-side for 1 minute. Rate-limited to
        30 requests per minute per client IP (token bucket, burst 30); over the limit returns `429 rateLimited`
        with `Retry-After`. Accepts no query parameters other than `apiKey`.
      security: []
      responses:
        '200':
          description: Latest headlines.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control'}
          content:
            application/json:
              schema:
                type: object
                required: [status, feedStatus, articles]
                properties:
                  status: {type: string, const: ok}
                  feedStatus: {type: string, enum: [current, stale, empty], description: Current means no older articles were needed, stale means articles up to 2 hours old supplemented the 15-minute window, and empty means no articles met the 2-hour maximum age.}
                  articles:
                    type: array
                    maxItems: 100
                    items: {$ref: '#/components/schemas/Headline'}
              example:
                status: ok
                feedStatus: current
                articles:
                  - id: '-4210765838120375291'
                    title: Central bank holds rates steady amid cooling inflation
                    url: 'https://www.dailyledger.example/business/rates-hold'
                    publishedAt: '2026-11-01T05:42:10Z'
                    indexedAt: '2026-11-01T05:42:12Z'
                    language: en
                    country: gb
                    source: {name: The Daily Ledger, domain: dailyledger.example}
        '400': {$ref: '#/components/responses/BadRequest'}
        '405': {$ref: '#/components/responses/MethodNotAllowed'}
        '429':
          description: More than 30 requests per minute from this IP (`rateLimited`); no credits are charged.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            Retry-After: {$ref: '#/components/headers/Retry-After'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
              example: {status: error, code: rateLimited, message: 'Rate limit exceeded: this public endpoint allows 30 requests per minute per IP. Get a free API key for more.', requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
        '500': {$ref: '#/components/responses/InternalError'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
  /v1/publisher-icons/{domain}:
    get:
      tags: [Public]
      operationId: getPublisherIcon
      summary: Get a publisher icon
      description: |
        Fetches an enabled publisher's declared site icon using that publisher's website as the
        Referer and Origin, then caches the image for up to one year. Declared page icons are
        preferred (sizes near 48px first); Google's favicon service is the fallback, and `/favicon.ico`
        is tried last. Enabled source domains and a fixed allowlist of featured publishers are accepted.
      security: []
      parameters:
        - name: domain
          in: path
          required: true
          description: The publisher website hostname, without a scheme or path.
          schema:
            type: string
            pattern: '^[A-Za-z0-9.-]+$'
      responses:
        '200':
          description: Publisher icon bytes.
          headers:
            Cache-Control:
              schema: {type: string}
              example: public, max-age=31536000, immutable
            ETag:
              schema: {type: string}
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
          content:
            image/*:
              schema: {type: string, format: binary}
        '404':
          description: No enabled publisher matches the requested domain.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429':
          description: Publisher icon request rate limit exceeded.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '503': {$ref: '#/components/responses/ServiceUnavailable'}
  /health:
    get:
      tags: [Public]
      operationId: health
      summary: Health check
      description: Liveness. With `deep=1` also checks the accounts DB, the sites DB and the isx archive (5 s timeout).
      security: []
      parameters:
        - name: deep
          in: query
          schema: {type: string, enum: ['1', '0', 'true', 'false']}
          description: Run dependency checks (case-insensitive).
      responses:
        '200':
          description: Healthy. `checks` is present only with `deep=1`.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Health'}
              examples:
                shallow: {value: {status: ok}}
                deep: {value: {status: ok, checks: {newsDb: ok, sitesDb: ok, isx: ok}}}
        '400': {$ref: '#/components/responses/BadRequest'}
        '405': {$ref: '#/components/responses/MethodNotAllowed'}
        '503':
          description: A dependency check failed (deep only).
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Health'}
              example: {status: error, code: serviceUnavailable, message: One or more dependencies are unavailable., checks: {newsDb: ok, sitesDb: ok, isx: error}, requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Recommended for server-side calls because keys stay out of request URLs and logs.
    BearerAuth:
      type: http
      scheme: bearer
      description: 'Also accepts API keys in the Authorization header using bearer authentication.'
    ApiKeyQuery:
      type: apiKey
      in: query
      name: apiKey
      description: Convenient for browser tests; query values may be retained by browser history, proxies, or analytics.
  parameters:
    Q:
      name: q
      in: query
      description: "Search syntax (max 500 characters): quoted phrases, AND/OR/NOT and parentheses; max 10 boolean operators."
      schema: {type: string, maxLength: 500}
      example: '"climate summit" OR elections'
    Country:
      name: country
      in: query
      description: Comma-separated ISO 3166-1 alpha-2 codes (max 10, case-insensitive). Matches any of them.
      schema: {type: string}
      example: us,gb
    Language:
      name: language
      in: query
      description: Comma-separated ISO 639-1 codes (max 10, case-insensitive). Matches any of them.
      schema: {type: string}
      example: en,fr
    Page:
      name: page
      in: query
      description: 1-based page number.
      schema: {type: integer, minimum: 1, maximum: 10000, default: 1}
  headers:
    X-Request-Id:
      description: Unique request id (ULID). Quote it when contacting support.
      schema: {type: string, pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'}
      example: 01JBDX4Q8Z6M3N2P1R0S9T8V7W
    X-RateLimit-Limit:
      description: Uniform gateway requests-per-minute limit shared by all plans.
      schema: {type: integer}
      example: 15
    X-RateLimit-Remaining:
      description: Whole requests remaining in the gateway rate-limit window.
      schema: {type: integer}
      example: 29
    X-RateLimit-Reset:
      description: Unix time (seconds, rounded up) when the gateway rate limit resets.
      schema: {type: integer}
      example: 1793512900
    X-Credits-Charged:
      description: Credits debited for this response with five decimal places, including the base search charge and any history surcharge (zero for errors and non-article endpoints).
      schema: {type: number, minimum: 0, multipleOf: 0.00001}
      example: 1.20000
    X-Credits-Remaining:
      description: Spendable credit balance from sharedstack as a decimal string; `unknown` when an insufficient-credit response does not return a balance.
      schema: {type: string, pattern: '^(unknown|\d+(\.\d{1,5})?)$'}
      example: '198.00000'
    Retry-After:
      description: Seconds to wait before retrying (minimum 1).
      schema: {type: integer, minimum: 1}
      example: 1
    Cache-Control:
      description: '`public, max-age=<seconds until the server-side cache refreshes>`.'
      schema: {type: string}
      example: public, max-age=412
    Cache-Control-NoStore:
      description: Authenticated responses are never stored by HTTP caches.
      schema: {type: string, const: no-store}
      example: no-store
  schemas:
    ErrorCode:
      type: string
      enum: [parameterInvalid, apiKeyMissing, apiKeyInvalid, apiKeyRevoked, notFound,
        routeNotFound, methodNotAllowed, rateLimited, insufficientCredits, activePlanRequired, internalError, serviceUnavailable]
    Error:
      type: object
      required: [status, code, message, requestId]
      properties:
        status: {type: string, const: error}
        code: {$ref: '#/components/schemas/ErrorCode'}
        message: {type: string, description: Human-readable explanation; names the offending parameter for 400/403.}
        requestId: {type: string, description: Same value as the X-Request-Id header.}
    ArticleSource:
      type: object
      required: [name, domain]
      description: Publisher display name and website hostname; internal site IDs are never exposed.
      properties:
        name: {type: string, example: The Daily Ledger}
        domain: {type: string, example: dailyledger.example}
    Article:
      type: object
      required: [id, title, description, content, contentTruncated, url, imageUrl, author, publishedAt, language, country, region, categories, source, indexedAt, storyId, trustScore, corroboratedBy]
      properties:
        id: {type: string, pattern: '^-?[0-9]{1,20}$', description: Stable stored article id.}
        title: {type: string}
        description: {type: [string, 'null']}
        content: {type: string, description: 'Plain-text excerpt of the article body (every plan). Markdown links become link text and images are omitted; stored HTML is converted to text. Paragraphs are separated by blank lines. The excerpt is about 200 characters, ending at a sentence or paragraph boundary when possible, otherwise at a word boundary, followed by an ellipsis. Full article text is not returned.'}
        contentTruncated: {type: boolean, description: 'True when `content` is an excerpt ending with an ellipsis, which is the case on every plan.'}
        url: {type: string, format: uri}
        imageUrl: {type: [string, 'null'], format: uri}
        author: {type: [string, 'null']}
        publishedAt: {type: string, format: date-time, description: 'RFC 3339, UTC, second precision.'}
        language: {type: [string, 'null'], description: ISO 639-1 code (lowercase first two letters of the article locale).}
        country: {type: [string, 'null'], description: Lowercase country code.}
        region: {type: [string, 'null']}
        categories: {type: array, items: {type: string}, description: May be empty.}
        source: {$ref: '#/components/schemas/ArticleSource'}
        indexedAt: {type: [string, 'null'], format: date-time, description: 'Time this article version was last indexed; use with `indexed_from` for incremental sync.'}
        storyId: {type: [string, 'null'], description: Dedupe story identifier.}
        trustScore: {type: [number, 'null'], description: Article trust score.}
        corroboratedBy: {type: integer, minimum: 0, description: Number of corroborating publishers.}
    ArticleList:
      type: object
      required: [status, page, pageSize, articles]
      properties:
        status: {type: string, const: ok}
        totalResults: {type: integer, description: 'Total matches; present on page one only.'}
        nextPageToken: {type: [string, 'null'], description: Use as the cursor query parameter to continue after returned articles.}
        semanticLag:
          type: integer
          minimum: 0
          description: 'mode=semantic|hybrid only: newest article versions not yet in the semantic index (they can still match lexically in hybrid).'
        semanticState:
          type: string
          enum: [ok, indexing, stalled, not_built]
          description: 'mode=semantic|hybrid only: semantic index progress. stalled means indexing is far behind or not progressing.'
        page: {type: integer}
        pageSize: {type: integer}
        articles: {type: array, items: {$ref: '#/components/schemas/Article'}}
    Counts:
      type: object
      required: [status, from, to, interval, series, shareOfVoice]
      properties:
        status: {type: string, const: ok}
        from: {type: string, format: date, description: First counted UTC day (inclusive).}
        to: {type: string, format: date, description: Last counted UTC day (inclusive).}
        interval: {type: string, enum: [day, week]}
        series:
          type: array
          description: One entry per `q`, in request order.
          items:
            type: object
            required: [query, total, buckets, topSources, topCountries, topLanguages]
            properties:
              query: {type: string, description: The `q` value as sent.}
              total: {type: integer, minimum: 0, description: Matching articles in the whole range.}
              buckets:
                type: array
                description: Article count per interval, oldest first.
                items:
                  type: object
                  required: [date, count]
                  properties:
                    date: {type: string, format: date, description: First UTC day of the bucket (Monday for weeks).}
                    count: {type: integer, minimum: 0}
              topSources:
                type: array
                description: Up to 5 publisher domains with the most matches.
                items: {type: object, required: [source, count], properties: {source: {type: string}, count: {type: integer, minimum: 0}}}
              topCountries:
                type: array
                description: Up to 5 lowercase country codes with the most matches.
                items: {type: object, required: [country, count], properties: {country: {type: string}, count: {type: integer, minimum: 0}}}
              topLanguages:
                type: array
                description: Up to 5 ISO 639-1 language codes with the most matches.
                items: {type: object, required: [language, count], properties: {language: {type: string}, count: {type: integer, minimum: 0}}}
        shareOfVoice:
          type: array
          description: Each query's percentage of the summed totals (one decimal; all 0 when the sum is 0).
          items:
            type: object
            required: [query, percent]
            properties:
              query: {type: string}
              percent: {type: number, minimum: 0, maximum: 100}
    Stats:
      type: object
      required: [status, sources, countries, languages, updatedAt]
      properties:
        status: {type: string, const: ok}
        sources: {type: integer, description: Enabled sources.}
        countries: {type: integer, description: Distinct countries among enabled sources.}
        languages: {type: integer, description: Distinct article languages published in the last 30 days.}
        updatedAt: {type: string, format: date-time, description: When coverage figures were computed.}
    Headline:
      type: object
      required: [id, title, url, publishedAt, indexedAt, language, country, source]
      properties:
        id: {type: string, pattern: '^-?[0-9]{1,20}$'}
        title: {type: string}
        url: {type: string, format: uri}
        publishedAt: {type: string, format: date-time}
        indexedAt: {type: [string, 'null'], format: date-time, description: 'Time this article version was last indexed; use with `indexed_from` for incremental sync; null when unavailable.'}
        language: {type: [string, 'null']}
        country: {type: [string, 'null']}
        source:
          type: object
          required: [name, domain]
          properties:
            name: {type: string}
            domain: {type: string, description: Publisher website hostname.}
    Health:
      type: object
      required: [status]
      properties:
        status: {type: string, enum: [ok, error]}
        code: {type: string, const: serviceUnavailable, description: Present only on 503.}
        message: {type: string, description: Present only on 503.}
        checks:
          type: object
          description: Present only with `deep=1`.
          additionalProperties: {type: string, enum: [ok, error]}
        requestId: {type: string, description: Present only on 503.}
  responses:
    AuthenticatedBadRequest:
      description: Invalid parameter after authentication; no credits are charged.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
        Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
        X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
        X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
        X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
        X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
        X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    AuthenticatedForbiddenPlan:
      description: No active sharedstack plan or subscription; no credits are charged. Revoked-key responses may omit these headers.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
        Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
        X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
        X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
        X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
        X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
        X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    InsufficientCredits:
      description: Sharedstack could not reserve the maximum charge for this article request (`insufficientCredits`). `X-Credits-Remaining` is `unknown` if Stack does not return a balance.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
        Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
        X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
        X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
        X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
        X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
        X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {status: error, code: insufficientCredits, message: 'Insufficient credits: this request requires up to 2.00 credits. Add credits or request a smaller page_size.', requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
    AuthenticatedTooManyRequests:
      description: The uniform gateway rate limit was exceeded; no credits are charged.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
        Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
        Retry-After: {$ref: '#/components/headers/Retry-After'}
        X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
        X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
        X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
        X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    AuthenticatedInternalError:
      description: Unexpected server error after authentication; no credits are charged.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
        Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
        X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
        X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
        X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
        X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
        X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    AuthenticatedServiceUnavailable:
      description: Dependency unavailable after authentication; no credits are charged.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
        Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
        X-RateLimit-Limit: {$ref: '#/components/headers/X-RateLimit-Limit'}
        X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
        X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
        X-Credits-Charged: {$ref: '#/components/headers/X-Credits-Charged'}
        X-Credits-Remaining: {$ref: '#/components/headers/X-Credits-Remaining'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    BadRequest:
      description: Invalid parameter (`parameterInvalid`) — unknown/repeated parameter, bad format or out-of-range value.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          examples:
            unknown: {value: {status: error, code: parameterInvalid, message: "Unknown parameter 'limit'. Allowed parameters: q, search_in, country, language, category, domains, exclude_domains, region, author, from, to, indexed_from, sort, dedupe, mode, page, page_size, cursor.", requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}}
            format: {value: {status: error, code: parameterInvalid, message: "Parameter 'from' must be an ISO-8601 date (YYYY-MM-DD) or datetime (e.g. 2026-01-31T13:45:00Z); URL-encode '+' in offsets as %2B (got 'yesterday').", requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}}
            range: {value: {status: error, code: parameterInvalid, message: "Parameter 'page_size' must be an integer between 1 and 100 (got '500').", requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}}
    Unauthorized:
      description: Missing (`apiKeyMissing`) or invalid (`apiKeyInvalid`) API key.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          examples:
            missing: {value: {status: error, code: apiKeyMissing, message: "No API key provided. Send it in the X-Api-Key header, as 'Authorization: Bearer <key>', or as the apiKey query parameter.", requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}}
            invalid: {value: {status: error, code: apiKeyInvalid, message: The API key is invalid. Check it in your console at https://newssearchapi.com/console/keys., requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}}
    ForbiddenRevoked:
      description: The key was revoked (`apiKeyRevoked`).
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
        Cache-Control: {$ref: '#/components/headers/Cache-Control-NoStore'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {status: error, code: apiKeyRevoked, message: This API key has been revoked. Create a new key in your console., requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
    MethodNotAllowed:
      description: Only GET, HEAD and OPTIONS are supported (`methodNotAllowed`).
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
        Allow: {schema: {type: string, const: 'GET, HEAD, OPTIONS'}}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {status: error, code: methodNotAllowed, message: 'Method not allowed. This endpoint supports GET, HEAD and OPTIONS.', requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
    InternalError:
      description: Unexpected server error (`internalError`). Not billable.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {status: error, code: internalError, message: 'An internal error occurred. Please retry; contact support with the requestId if it persists.', requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
    ServiceUnavailable:
      description: A backing service is unavailable or timed out (`serviceUnavailable`). Not billable.
      headers:
        X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {status: error, code: serviceUnavailable, message: The service is temporarily unavailable. Please retry shortly., requestId: 01JBDX4Q8Z6M3N2P1R0S9T8V7W}
  examples:
    CountsOk:
      summary: Two queries, weekly buckets
      value:
        status: ok
        from: '2026-10-05'
        to: '2026-10-18'
        interval: week
        series:
          - query: electric vehicles
            total: 1840
            buckets: [{date: '2026-10-05', count: 910}, {date: '2026-10-12', count: 930}]
            topSources: [{source: dailyledger.example, count: 120}, {source: wire.example, count: 96}]
            topCountries: [{country: us, count: 702}, {country: gb, count: 311}]
            topLanguages: [{language: en, count: 1404}, {language: de, count: 188}]
          - query: hydrogen
            total: 460
            buckets: [{date: '2026-10-05', count: 220}, {date: '2026-10-12', count: 240}]
            topSources: [{source: wire.example, count: 41}]
            topCountries: [{country: de, count: 150}]
            topLanguages: [{language: en, count: 300}, {language: de, count: 120}]
        shareOfVoice:
          - {query: electric vehicles, percent: 80.0}
          - {query: hydrogen, percent: 20.0}
    ArticleListOk:
      summary: One page (excerpts)
      value:
        status: ok
        totalResults: 1342
        page: 1
        pageSize: 10
        articles:
          -
            id: '-4210765838120375291'
            title: Central bank holds rates steady amid cooling inflation
            description: Policymakers voted 7–2 to keep the benchmark rate unchanged.
            content: 'Policymakers voted 7–2 on Thursday to keep the benchmark rate unchanged, citing a steady decline in core inflation over the past quarter. The decision was widely expected by markets. …'
            contentTruncated: true
            url: 'https://www.dailyledger.example/business/rates-hold'
            imageUrl: 'https://cdn.dailyledger.example/img/rates.jpg'
            author: Jane Smith
            publishedAt: '2026-10-31T09:15:00Z'
            language: en
            country: gb
            region: Europe
            categories: [business, economy]
            source: {name: The Daily Ledger, domain: dailyledger.example}
            indexedAt: '2026-10-31T09:15:02Z'
            storyId: story-2026-10-31-001
            trustScore: 0.94
            corroboratedBy: 8
    ArticleOkTruncated:
      summary: Article (excerpt of about 200 characters)
      value:
        status: ok
        article:
          id: '-4210765838120375291'
          title: Central bank holds rates steady amid cooling inflation
          description: Policymakers voted 7–2 to keep the benchmark rate unchanged.
          content: 'Policymakers voted 7–2 on Thursday to keep the benchmark rate unchanged, citing a steady decline in core inflation over the past quarter. The decision was widely expected by markets. …'
          contentTruncated: true
          url: 'https://www.dailyledger.example/business/rates-hold'
          imageUrl: 'https://cdn.dailyledger.example/img/rates.jpg'
          author: Jane Smith
          publishedAt: '2026-10-31T09:15:00Z'
          language: en
          country: gb
          region: Europe
          categories: [business, economy]
          source: {name: The Daily Ledger, domain: dailyledger.example}
          indexedAt: '2026-10-31T09:15:02Z'
          storyId: story-2026-10-31-001
          trustScore: 0.94
          corroboratedBy: 8
