openapi: 3.1.0
info:
  title: TweetFeed API
  description: |
    Public REST API for TweetFeed (https://tweetfeed.live), a real-time aggregator of
    Indicators of Compromise (IOCs) - URLs, domains, IPs and file hashes - shared by
    the infosec community on Twitter/X.

    The dataset is refreshed every 15 minutes by scraping RSS feeds from a curated
    list of ~95 security researchers plus security-related hashtags. Coverage starts
    2021-08-09. All data is released under CC0 1.0 Universal (public domain
    dedication) - no attribution required, no warranty.

    All endpoints:
    - Require no authentication.
    - Set `Access-Control-Allow-Origin: *` (CORS open).
    - Cache responses at the CF edge: 300 s for `/v1/{time}`, `/v1/campaigns`,
      `/v1/campaigns/{id}` (plain, `.csv` and `.stix.json`), `/v1/campaigns/iocs`,
      `/v1/trends` and `/v1/counts` (`stale-while-revalidate=600,
      stale-if-error=86400`); 60 s for `/v1/ioc` and `/v1/manifest` (same
      conditional-request behaviour as `/v1/counts`); 30 s for `/v1/since`;
      15 min for `/v1/blocklist` (all with `stale-while-revalidate`).
      `/v1/status` is the exception: it is computed fresh on every request,
      never served from cache, and never conditional - `max-age=60` only, no
      `ETag`-based revalidation.
    - Return JSON arrays (one object per IOC), one schema for every route.

    For Threat Intelligence agent integrations there is also a Model Context Protocol
    server at `https://mcp.tweetfeed.live/` (JSON-RPC 2.0, 14 tools).
  version: "1.0"
  contact:
    name: TweetFeed
    url: https://github.com/0xDanielLopez/TweetFeed/issues
  license:
    name: CC0 1.0 Universal
    url: https://creativecommons.org/publicdomain/zero/1.0/

servers:
  - url: https://api.tweetfeed.live
    description: Production

externalDocs:
  description: API guide with copy-paste examples and FAQ.
  url: https://tweetfeed.live/api/

paths:
  /v1/{time}:
    get:
      operationId: getIocsByWindow
      summary: List IOCs in a time window
      description: |
        Returns all IOCs that landed in the chosen time window. The `today` window
        is the current UTC calendar day (from 00:00 UTC, not a rolling 24 h),
        `week` the last 7 d, `month` the last 30 d, `year` the last 365 d.

        **Result cap: 10 000 rows.** A response that hits the cap is truncated
        from the OLDEST end, so you keep the most recent rows and lose the start
        of the window. In practice only the unfiltered `month` window exceeds it
        (14 936 rows on 2026-07-30, so roughly the first ten days of the window
        are dropped); `today`, `week` and every filtered variant are comfortably
        below it. Raising the cap is not possible on the Free plan: the month
        payload is already ~2.7 MB and is what trips the Worker's 10 ms CPU
        budget.

        Every `200` reports what you actually got, so truncation is never
        silent: `X-Result-Count`, plus `X-Result-Window-Start` / `X-Result-Window-End`
        for the real date range of the payload, and `X-Result-Truncated: true`
        only when the cap was reached. **If you need the complete 30-day window,
        fetch the CSV feed at `https://tweetfeed.live/feeds/month.csv`** - it is
        served in full with no cap.

        Note: `year` returns `302` to the raw CSV on GitHub because the full year
        dataset exceeds the 10 ms CPU budget of the underlying Cloudflare Worker
        on the Free plan. Clients should follow redirects; the redirect target
        is a raw CSV, never defanged, regardless of `?defang=1`.
      parameters:
        - $ref: "#/components/parameters/Time"
        - $ref: "#/components/parameters/Defang"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: JSON array of IOCs.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
            X-Result-Count:
              $ref: "#/components/headers/ResultCount"
            X-Result-Truncated:
              $ref: "#/components/headers/ResultTruncated"
            X-Result-Window-Start:
              $ref: "#/components/headers/ResultWindowStart"
            X-Result-Window-End:
              $ref: "#/components/headers/ResultWindowEnd"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Ioc"
              example:
                - date: "2026-05-21 09:13:00"
                  user: "malwrhunterteam"
                  type: "url"
                  value: "http://example.malicious/path"
                  tags: ["phishing", "scam"]
        "302":
          description: Only for `time=year`. Redirects to the raw CSV on GitHub.
          headers:
            Location:
              schema:
                type: string
                example: https://raw.githubusercontent.com/0xDanielLopez/TweetFeed/master/year.csv
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/DefangInvalid"

  /v1/{time}/{filter1}:
    get:
      operationId: getIocsByWindowFilter
      summary: List IOCs in a window, filtered once
      description: |
        Same as `/v1/{time}` but filtered by one of:
        - **IOC type** - `url`, `domain`, `ip`, `sha256`, `md5`
        - **Tag** - e.g. `phishing`, `cobaltstrike`, `lockbit`, `ransomware` (full
          taxonomy: 94 tags, see https://tweetfeed.live/tags/)
        - **Researcher handle** - must include the leading `@`, e.g.
          `@malwrhunterteam`
      parameters:
        - $ref: "#/components/parameters/Time"
        - $ref: "#/components/parameters/Filter1"
        - $ref: "#/components/parameters/Defang"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: JSON array of IOCs.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
            X-Result-Count:
              $ref: "#/components/headers/ResultCount"
            X-Result-Truncated:
              $ref: "#/components/headers/ResultTruncated"
            X-Result-Window-Start:
              $ref: "#/components/headers/ResultWindowStart"
            X-Result-Window-End:
              $ref: "#/components/headers/ResultWindowEnd"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Ioc"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/DefangInvalid"

  /v1/{time}/{filter1}/{filter2}:
    get:
      operationId: getIocsByWindowTwoFilters
      summary: List IOCs in a window, two filters AND-ed
      description: |
        Two filters combined with AND semantics. Common patterns:
        - **tag + type** - `/v1/week/phishing/url` (phishing URLs in last 7 d)
        - **tag + tag** - `/v1/month/cobaltstrike/c2`
        - **@user + type** - `/v1/today/@malwrhunterteam/sha256`
      parameters:
        - $ref: "#/components/parameters/Time"
        - $ref: "#/components/parameters/Filter1"
        - $ref: "#/components/parameters/Filter2"
        - $ref: "#/components/parameters/Defang"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: JSON array of IOCs.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
            X-Result-Count:
              $ref: "#/components/headers/ResultCount"
            X-Result-Truncated:
              $ref: "#/components/headers/ResultTruncated"
            X-Result-Window-Start:
              $ref: "#/components/headers/ResultWindowStart"
            X-Result-Window-End:
              $ref: "#/components/headers/ResultWindowEnd"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Ioc"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/DefangInvalid"

  /v1/since/{datetime}:
    get:
      operationId: getIocsSince
      summary: List IOCs added since an ISO 8601 datetime
      description: |
        Returns IOCs added after the given ISO 8601 timestamp. Designed for
        delta-syncing a blocklist or Threat Intelligence pipeline without
        re-pulling the full window.

        Strict ISO 8601 required (`Z` UTC suffix or `+HH:MM` offset; ambiguous
        local-time forms are rejected). Edge cases:
        - Future timestamp - `200` with `[]` body.
        - Timestamp older than 365 days - `410 Gone`.
        - Malformed, or `defang` present but not `1`/`true`/`0`/`false` - `400`.

        **Reaches back 30 days, not 365.** This endpoint is served from the
        30-day window file; the full year is too large to scan inside the
        Worker's CPU budget. A `since` older than that still returns `200`
        with the 30 days it can serve, and says so with
        `X-Result-Window-Incomplete: true` plus an `X-Result-Window-Start`
        showing where the data really begins - it is never silently short.
        For a deeper backfill fetch `https://tweetfeed.live/feeds/year.csv`
        (365 days, uncapped) once, then switch to this endpoint for the
        incremental updates it is built for.

        **Result cap: 10 000 rows**, same as `/v1/{time}` but truncated from the
        opposite end. This endpoint returns rows oldest-first, so the cap drops
        the NEWEST ones and you are left with a contiguous block starting at your
        `since`. That is what makes it safe to page: when `X-Result-Truncated`
        is present, pass `X-Result-Window-End` back as the next `since` and
        repeat until the header stops appearing.
      parameters:
        - $ref: "#/components/parameters/DateTime"
        - $ref: "#/components/parameters/Defang"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: JSON array of IOCs.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
            X-Result-Count:
              $ref: "#/components/headers/ResultCount"
            X-Result-Truncated:
              $ref: "#/components/headers/ResultTruncated"
            X-Result-Window-Start:
              $ref: "#/components/headers/ResultWindowStart"
            X-Result-Window-End:
              $ref: "#/components/headers/ResultWindowEnd"
            X-Result-Window-Incomplete:
              $ref: "#/components/headers/ResultWindowIncomplete"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Ioc"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          description: |
            Malformed ISO 8601 timestamp, or `defang` present but not `1`,
            `true`, `0` or `false` (in the latter case, JSON body
            `{"error": "defang must be 1 or 0"}`).
        "410":
          description: Timestamp older than the 365 day retention window.

  /v1/since/{datetime}/{filter1}:
    get:
      operationId: getIocsSinceFilter
      summary: List IOCs since a datetime, filtered once
      parameters:
        - $ref: "#/components/parameters/DateTime"
        - $ref: "#/components/parameters/Filter1"
        - $ref: "#/components/parameters/Defang"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: JSON array of IOCs.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
            X-Result-Count:
              $ref: "#/components/headers/ResultCount"
            X-Result-Truncated:
              $ref: "#/components/headers/ResultTruncated"
            X-Result-Window-Start:
              $ref: "#/components/headers/ResultWindowStart"
            X-Result-Window-End:
              $ref: "#/components/headers/ResultWindowEnd"
            X-Result-Window-Incomplete:
              $ref: "#/components/headers/ResultWindowIncomplete"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Ioc"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/DefangInvalid"

  /v1/since/{datetime}/{filter1}/{filter2}:
    get:
      operationId: getIocsSinceTwoFilters
      summary: List IOCs since a datetime, two filters AND-ed
      parameters:
        - $ref: "#/components/parameters/DateTime"
        - $ref: "#/components/parameters/Filter1"
        - $ref: "#/components/parameters/Filter2"
        - $ref: "#/components/parameters/Defang"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: JSON array of IOCs.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
            X-Result-Count:
              $ref: "#/components/headers/ResultCount"
            X-Result-Truncated:
              $ref: "#/components/headers/ResultTruncated"
            X-Result-Window-Start:
              $ref: "#/components/headers/ResultWindowStart"
            X-Result-Window-End:
              $ref: "#/components/headers/ResultWindowEnd"
            X-Result-Window-Incomplete:
              $ref: "#/components/headers/ResultWindowIncomplete"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Ioc"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/DefangInvalid"

  /v1/ioc:
    get:
      operationId: getIocByValue
      summary: Exact-match lookup for a single IOC
      description: |
        Looks up one IOC value across the full 365-day retention window and
        returns an exact match - not a substring search. Accepts defanged
        input (`hxxp://`, `[.]`, etc.); the value is normalised (refanged)
        server-side before the lookup, same as on ingest.

        Also checked against the archive (everything published before the
        365-day window, back to `first_date` in `TweetFeed/archive/meta.json`
        - the coverage start moves daily); any hit comes back in the
        optional `archive` block, additive to `found`/`records`.

        Also available as a path parameter: `/v1/ioc/{value}`.
      parameters:
        - $ref: "#/components/parameters/IocValueQuery"
        - $ref: "#/components/parameters/Defang"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: |
            Lookup result. `found: false` with an empty `records` array
            means no match in the live 365-day window - check the optional
            `archive` block before treating that as "never seen".
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IocLookupResult"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/DefangInvalid"

  /v1/ioc/{value}:
    get:
      operationId: getIocByValuePath
      summary: Exact-match lookup for a single IOC (path form)
      description: |
        Same lookup as `/v1/ioc?value=...`, with the IOC value passed as a
        path segment instead of a query parameter, including the same
        optional `archive` block.

        `?defang=1` is supported on this path form too. Fixed alongside
        adding it: the query string used to get folded into the looked-up
        `{value}` path segment itself, so `/v1/ioc/example.com?defang=1`
        looked up the literal string `example.com?defang=1` instead of
        `example.com`.
      parameters:
        - $ref: "#/components/parameters/IocValuePath"
        - $ref: "#/components/parameters/Defang"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: |
            Lookup result. `found: false` with an empty `records` array
            means no match in the live 365-day window - check the optional
            `archive` block before treating that as "never seen".
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IocLookupResult"
        "304":
          $ref: "#/components/responses/NotModified"
        "400":
          $ref: "#/components/responses/DefangInvalid"

  /v1/campaigns:
    get:
      operationId: getCampaigns
      summary: List AI-clustered threat campaigns
      description: |
        Returns AI-clustered campaign groupings of the last 30 days of community
        IOCs. Clustering is two-stage: deterministic pre-grouping (shared
        registered domain, cross-domain URL path patterns, or a shared specific
        tag), followed by AI naming and context generation. The AI only names
        and describes clusters - it never adds or removes IOCs; every IOC in a
        campaign is verbatim from the feed.

        Regenerated once a day. If a run fails, `stale` is `true` and the
        document falls back to the previous day's snapshot (`stale_since`
        holds the date of that last successful run).
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Campaigns document.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CampaignsDocument"
        "304":
          $ref: "#/components/responses/NotModified"

  /v1/campaigns/iocs:
    get:
      operationId: getCampaignIocs
      summary: Full IOC membership of every campaign
      description: |
        Returns the complete per-campaign IOC list for the campaigns returned
        by `/v1/campaigns` - same generator, same daily regeneration, same
        campaign ids. Served separately so the `/v1/campaigns` document stays
        small (that endpoint only ships a 25-IOC sample per campaign); every
        IOC here is verbatim from the feed.
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Campaign IOC membership document.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CampaignIocsDocument"
        "304":
          $ref: "#/components/responses/NotModified"

  /v1/campaigns/{id}:
    get:
      operationId: getCampaign
      summary: Full record for one AI-clustered campaign
      description: |
        Returns one campaign's header (the same object as an entry of
        `/v1/campaigns`) plus its complete, unsampled IOC membership - no
        25-row cap. Same generator, same daily regeneration, same campaign
        ids as `/v1/campaigns` and `/v1/campaigns/iocs`.

        CSV and STIX 2.1 variants of the same data are available at
        `/v1/campaigns/{id}.csv` and `/v1/campaigns/{id}.stix.json`.
      parameters:
        - $ref: "#/components/parameters/CampaignId"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Campaign export document.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CampaignExport"
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          description: Unknown or expired campaign id.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
              example:
                error: "campaign not found"

  /v1/campaigns/{id}.csv:
    get:
      operationId: getCampaignCsv
      summary: One campaign's IOC membership as CSV
      description: |
        Same IOC rows as the `iocs` array of `/v1/campaigns/{id}`, as a CSV
        with the same 6 columns as `today.csv` (date, user, type, value,
        tags, tweet_url). No campaign header - fetch `/v1/campaigns/{id}`
        for that.
      parameters:
        - $ref: "#/components/parameters/CampaignId"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Campaign IOC membership, CSV.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            text/csv:
              schema:
                type: string
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          description: Unknown or expired campaign id.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
              example:
                error: "campaign not found"

  /v1/campaigns/{id}.stix.json:
    get:
      operationId: getCampaignStix
      summary: One campaign's IOC membership as a STIX 2.1 bundle
      description: |
        Same IOCs as `/v1/campaigns/{id}`, as a spec-compliant, self-contained
        STIX 2.1 bundle: the TweetFeed Identity, the canonical TLP:CLEAR
        marking-definition, and one Indicator object per IOC. Deterministic
        bundle id - reimporting the same campaign later does not create a
        duplicate object in platforms that dedupe by id.
      parameters:
        - $ref: "#/components/parameters/CampaignId"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Campaign STIX 2.1 bundle.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/stix+json;version=2.1:
              schema:
                type: object
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          description: Unknown or expired campaign id.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
              example:
                error: "campaign not found"

  /v1/trends:
    get:
      operationId: getTrends
      summary: Community IOC trend analytics
      description: |
        Returns the trends document: a rolling 31-day daily IOC volume series
        (with a per-type breakdown), week-over-week top movers by tag, the
        most-abused TLDs among malicious domains from the last 30 days, and
        the novelty rate (share of this week's distinct IOC values that are
        new versus recurring). Regenerated alongside the rest of the feed.
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Trends document.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TrendsDocument"
        "304":
          $ref: "#/components/responses/NotModified"

  /v1/contributors:
    get:
      operationId: getContributors
      summary: Per-window reporter leaderboard
      description: |
        Returns the contributors document: today/week/month/year rankings of
        X/Twitter handles by IOCs reported, each entry carrying its top 3 IOC
        types and top 3 tags. Consumed by /researchers/ to render all four
        windows from one fetch instead of ranking CSVs in the browser.
        Refreshed every 15 minutes alongside the rest of the feed. Handles
        only - no personal data beyond the public X handle.
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Contributors document.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContributorsDocument"
        "304":
          $ref: "#/components/responses/NotModified"

  /v1/blocklist/{file}:
    get:
      operationId: getBlocklist
      summary: Download a plain-text blocklist export
      description: |
        Ready-to-consume plain-text exports of the last 30 days, one indicator
        per line, for direct use in Pi-hole, AdGuard Home, hosts files and
        firewalls. This is a 1:1 mirror of the feed - no additional quality
        gate beyond the standard pipeline. Community-reported IOCs, use at
        your own risk.

        `urls.txt` is the exception: it carries the full URL, not just the
        host, so it also covers `url`-type IOCs hosted on shared or otherwise
        legitimate infrastructure (a public code host, a cloud storage
        bucket, a bare-IP host) that host/DNS blocking can't safely reach
        without collateral damage - meant for proxies, IDS and enrichment
        pipelines that can match on the full path.

        Rebuilt every 15 minutes, but a file's content (and its `# Updated:`
        header) only changes when its entries actually change.

        `zeek-intel.txt`, `wazuh-domains.txt` and `wazuh-ips.txt` carry no `#`
        comment header at all: `zeek-intel.txt` opens with a single `#fields`
        column-header line instead, and the two Wazuh files have no header
        line, since a `#` would compile as a literal CDB key. Check freshness
        for these three with `/v1/manifest` or the response's `Last-Modified`
        header instead of the `# Updated:` line.

        `domains-corroborated.txt`, `ips-corroborated.txt` and
        `urls-corroborated.txt` are the subset of `domains.txt` / `ips.txt` /
        `urls.txt` also listed by at least one of TweetFeed's 6-hourly
        cross-checked feeds (URLhaus, ThreatFox, MalwareBazaar, USOM, IPsum -
        the same set `/v1/ioc`'s `external` array draws from). Same header
        format plus three extra lines: which base list this is a subset of,
        when the cross-check was last refreshed, and a reminder that this is
        set membership only, not a quality score or a verdict. If the
        cross-check file is unavailable, these three files simply keep their
        last content.

        `sha256.txt` and `md5.txt` are one lowercase hex hash per line (SHA-256
        and MD5 respectively), same 30-day window and 15-minute regeneration
        as the other files, for EDR/AV custom-indicator bulk import (Microsoft
        Defender for Endpoint's Indicators CSV import, CrowdStrike IOC
        management, etc.). Hash coverage is smaller than domains/URLs, since
        researchers tweet fewer hashes.

        `adguard.txt` carries AdGuard/uBlock filter-list metadata (`! Title:`,
        `! Description:`, `! Homepage:`, `! License:`, `! Expires:`,
        `! Last modified:`, `! Entries:`, `! Source:`, a poll-etiquette line)
        instead of the generic `#` header used by the other files; the body
        (`||domain^` lines) is unchanged. This makes it a valid filter list
        for AdGuard Home, uBlock Origin custom lists and filter-list
        registries.

        `nrd-domains.txt` is the subset of `domains.txt` whose registrable
        apex had an RDAP creation date 30 days or less before TweetFeed's
        first report. It carries the standard `#` header plus two extra
        lines: which base list it is derived from, and when the RDAP
        cross-check was last refreshed. Two limits apply: only apexes with a
        known RDAP creation date are considered (about 6 in 10 reported
        domains), and a domain reported in the current tick can appear one
        tick later, since the registration check runs after publish.
      parameters:
        - $ref: "#/components/parameters/BlocklistFile"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: |
            Plain-text blocklist, starting with a comment header block. The
            `# Updated:` line reflects the last time the entries actually
            changed, not the last regeneration.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            text/plain:
              schema:
                type: string
              example: |
                # TweetFeed blocklist - plain domain list (Pi-hole compatible)
                # Window: 30 days
                # Entries: 4399
                # Updated: 2026-07-12T21:11:25Z
                # License: CC0
                # Community-reported IOCs. Use at your own risk.

                008963466.weebly.com
                06updatelive.xyz
        "304":
          $ref: "#/components/responses/NotModified"
        "404":
          description: Unknown file name (outside the allowlist below).

  /taxii2/:
    get:
      operationId: getTaxiiDiscovery
      summary: TAXII 2.1 discovery endpoint
      description: |
        TAXII 2.1 (read-only) discovery document. Points to the API root at
        `/taxii2/root/`, which exposes the standard TAXII 2.1 collection
        endpoints (`/collections/`, `/collections/{id}/objects/`, etc.) for
        one rolling 31-day collection of community IOCs modeled as STIX 2.1
        objects. No authentication, CC0.

        The full TAXII route surface is intentionally not modeled path-by-path
        here - see the
        [TAXII 2.1 specification](https://docs.oasis-open.org/cti/taxii/v2.1/taxii-v2.1.html)
        and https://tweetfeed.live/api/ for the collection ID and the
        incremental-poll pattern (`added_after` on `/objects/`).
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: TAXII 2.1 discovery document.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/taxii+json;version=2.1:
              schema:
                type: object
        "304":
          $ref: "#/components/responses/NotModified"

  /v1/counts:
    get:
      operationId: getCounts
      summary: Per-window totals, type/tag breakdowns and date bounds
      description: |
        Returns pre-aggregated counts for the four standard windows
        (`today`, `week`, `month`, `year`): total IOCs, a per-type
        breakdown, a per-tag breakdown, and the first/last IOC timestamp
        in that window. Lets a client render dashboard-style stats without
        fetching and parsing the full `year` CSV (~120k rows). Regenerated
        every 15 minutes alongside the rest of the feed.
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Counts document.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CountsDocument"
        "304":
          $ref: "#/components/responses/NotModified"

  /v1/status:
    get:
      operationId: getStatus
      summary: Per-artifact freshness facts plus a per-request verdict
      description: |
        Freshness facts for every published artifact plus a per-request
        verdict: whether the pipeline is stale, how old each artifact is,
        and (for tick artifacts) how old the newest row inside it is.

        Computed fresh on every request - never served from the response
        cache. That also means it is never conditional: there is no
        `If-None-Match`/`If-Modified-Since` support and no `304` here
        (its `ETag` changes on every request). Cache with `cache-control:
        public, max-age=60` instead.

        The underlying `status.json` is re-read from the data repo at most
        every 5 minutes, with no stale-while-revalidate; once that window
        expires the request blocks on a fresh read, falling back to the
        last cached copy only if that read fails.

        Also carries `sources`: per-X-source facts (hashtag/account feeds,
        rows attributed in the last 24h) with dead-account verdicts computed
        per request, same as the top-level `stale`.
      responses:
        "200":
          description: Status document.
          headers:
            X-Feed-Stale:
              $ref: "#/components/headers/FeedStale"
            X-Feed-Age-Seconds:
              $ref: "#/components/headers/FeedAgeSeconds"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusDocument"
        "503":
          description: The upstream status.json document is unavailable.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string

  /v1/manifest:
    get:
      operationId: getManifest
      summary: Every published artifact - size, row count, checksum, URLs
      description: |
        Lists every published artifact: byte size, row count, SHA-256,
        last-updated timestamp, the raw GitHub URL and (where one exists)
        the API URL, plus a `families` list for sharded/multi-file
        surfaces (`lookup/`, `misp/`, `stix/`, `archive/`, `campaigns/`,
        per-tag/type/user RSS) that don't reduce to one row.

        `sha256`/`bytes`/`rows` describe the files in the same data-repo
        commit as this manifest - the API resolves `master` fresh on
        every fetch, so across a tick boundary the hash you compute
        locally may not match what `/v1/*` is serving at that instant.

        Same cache/conditional-request behaviour as `/v1/counts`.
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: Manifest document.
          headers:
            ETag:
              $ref: "#/components/headers/ETag"
            Last-Modified:
              $ref: "#/components/headers/LastModified"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ManifestDocument"
        "304":
          $ref: "#/components/responses/NotModified"

components:
  parameters:
    CampaignId:
      name: id
      in: path
      required: true
      description: |
        Campaign identifier - `tfc-` followed by 12 lowercase hex chars, as
        returned by `/v1/campaigns`.
      schema:
        type: string
        pattern: '^tfc-[0-9a-f]{12}$'
      example: "tfc-6d1f6fb9260c"

    Time:
      name: time
      in: path
      required: true
      description: |
        Time window for the query.

        `year` is only meaningful on the unfiltered `/v1/{time}` route. On the
        filtered routes it does NOT filter: the request still returns `302` to
        the full, unfiltered `year.csv`, so `/v1/year/phishing/url` yields every
        IOC of every type from every researcher. Filter the CSV client-side, or
        use `month` if a 30-day window is enough.
      schema:
        type: string
        enum: [today, week, month, year]

    Filter1:
      name: filter1
      in: path
      required: true
      description: |
        First filter. Accepts an IOC type (`url`, `domain`, `ip`, `sha256`, `md5`),
        a tag (e.g. `phishing`, `cobaltstrike`), or a researcher handle prefixed
        with `@` (e.g. `@malwrhunterteam`).
      schema:
        type: string
      examples:
        type:
          value: url
          summary: IOC type
        tag:
          value: phishing
          summary: Tag
        researcher:
          value: "@malwrhunterteam"
          summary: Researcher handle

    Filter2:
      name: filter2
      in: path
      required: true
      description: Second filter, AND-ed with `filter1`. Same value space as `filter1`.
      schema:
        type: string

    DateTime:
      name: datetime
      in: path
      required: true
      description: |
        ISO 8601 datetime, strict form (must end in `Z` for UTC or include a
        `+HH:MM` / `-HH:MM` offset). Ambiguous local-time forms are rejected.
      schema:
        type: string
        format: date-time
        example: "2026-05-01T00:00:00Z"

    BlocklistFile:
      name: file
      in: path
      required: true
      description: |
        Blocklist export to download. `domains.txt` / `hosts.txt` /
        `adguard.txt` are the same domain-level dataset in three formats;
        `ips.txt` is the IP-level dataset. `rpz.txt` is a DNS Response
        Policy Zone (exact and wildcard `CNAME .` records, SOA serial =
        the epoch of the last content change) for BIND, Unbound and
        PowerDNS Recursor. `dnsmasq.txt` uses the `address=/<domain>/0.0.0.0`
        format. `urls.txt` is the only flat URL-level export: full URLs, not
        just hosts, for `url`-type IOCs on shared/legitimate infrastructure
        that host/DNS blocking can't safely cover. `zeek-intel.txt` is a
        Zeek Intelligence Framework file: a tab-separated `#fields`
        column-header line (`indicator`, `indicator_type`, `meta.source`,
        the last always `tweetfeed.live`), then domains as `Intel::DOMAIN`,
        IPs as `Intel::ADDR`/`Intel::SUBNET`, and schemeless URLs as
        `Intel::URL`. `wazuh-domains.txt` and `wazuh-ips.txt` are Wazuh CDB
        lists (`key:tweetfeed` per line, no header; IPv6 keys in
        `wazuh-ips.txt` are double-quoted, and CIDR values are omitted
        because Wazuh matches subnets by dotted-prefix key (`192.168.`),
        so a `1.2.3.0/24` key would never match). `domains-corroborated.txt`,
        `ips-corroborated.txt` and `urls-corroborated.txt` are the subset of
        `domains.txt` / `ips.txt` / `urls.txt` also listed by at least one of
        URLhaus, ThreatFox, MalwareBazaar, USOM or IPsum as of the last
        6-hourly cross-check - set membership only, not a quality score.
        `sha256.txt` and `md5.txt` are one lowercase hex hash per line, for
        EDR/AV bulk import. `nrd-domains.txt` is the subset of `domains.txt`
        whose registrable apex was created 30 days or less before TweetFeed's
        first report (RDAP creation date).
      schema:
        type: string
        enum: [domains.txt, hosts.txt, adguard.txt, ips.txt, rpz.txt, dnsmasq.txt, urls.txt, zeek-intel.txt, wazuh-domains.txt, wazuh-ips.txt, domains-corroborated.txt, ips-corroborated.txt, urls-corroborated.txt, sha256.txt, md5.txt, nrd-domains.txt]

    IocValueQuery:
      name: value
      in: query
      required: true
      description: |
        IOC value to look up (URL, domain, IP, or hash). Accepts defanged
        input - `hxxp://`, `[.]`, etc. - normalised server-side before the
        exact-match lookup.
      schema:
        type: string
      example: "example.malicious.com"

    IocValuePath:
      name: value
      in: path
      required: true
      description: Same as the `value` query parameter, passed as a path segment.
      schema:
        type: string

    Defang:
      name: defang
      in: query
      required: false
      description: |
        Defang IOC values in the response body: `hxxps://evil[.]tld` and
        `1[.]2[.]3[.]4` instead of live, clickable values. `1` or `true`
        turns it on; absent, empty, `0` or `false` leaves the response
        byte-identical to the same request with no `defang` parameter. Any
        other value returns `400` with `{"error": "defang must be 1 or 0"}`.

        Transform: the scheme (`http://`, `https://`, `ftp://`, `ftps://`)
        becomes `hxxp://`, `hxxps://`, `fxp://`, `fxps://` (lowercased,
        only when followed by `://`); every literal `.` in the value
        becomes `[.]`, in the host, path and query alike. For example,
        `https://evil.tld/a.php?id=1.2` becomes
        `hxxps://evil[.]tld/a[.]php?id=1[.]2`, and `171.229.217.2` becomes
        `171[.]229[.]217[.]2`. Ports, `://` and `@` are left untouched, and
        hashes are unaffected (no `.` to defang). Reversible with
        CyberChef's "Defang URL" / "Refang"
        recipes, or Python's `iocextract` (`refang_data` for URLs,
        `refang_data(no_scheme=True)` for bare domains, `refang_ipv4` for
        IPs) - a value that already contains `[` or `]` is lossy under
        `iocextract`, a pre-existing library behaviour, not something this
        parameter introduces.

        Applies only to the fields that carry a live IOC value on each
        route (see each field's own description below); never to
        `tweet`/`user`, `external[].link`, `net.*`, or `ai.summary` (which
        may still name a live hostname in prose). Accepted and validated
        on every route, but has no effect on the body of `/v1/campaigns`,
        `/v1/campaigns/iocs`, the blocklists, the RSS/CSV feeds, or
        TAXII/STIX.

        Same cache lifetime as the equivalent request without `defang` -
        `?defang=1` is cached as its own entry, separate from the plain
        response.
      schema:
        type: string
        enum: ["", "1", "true", "0", "false"]
      example: "1"

    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      description: |
        Conditional GET, strong validator. Send back the `ETag` value from a
        previous response; a match returns `304 Not Modified` with an empty
        body. This is the recommended validator - unlike `Last-Modified`, it
        changes only when the response body actually changes, not every time
        it is recomputed. Per RFC 7232 section 6, if both `If-None-Match` and
        `If-Modified-Since` are sent, `If-None-Match` takes precedence and
        `If-Modified-Since` is ignored.
      schema:
        type: string
      example: '"a1b2c3d4e5f60718"'

    IfModifiedSince:
      name: If-Modified-Since
      in: header
      required: false
      description: |
        Conditional GET, timestamp validator. Send back the `Last-Modified`
        value from a previous response; if the representation has not
        changed since, returns `304 Not Modified` with an empty body. Weaker
        than `If-None-Match`: `Last-Modified` reflects when this Worker
        cached the representation, not when the underlying data last
        changed, so it only saves a round trip while that cache entry is
        still alive. Ignored if `If-None-Match` is also present (RFC 7232
        section 6).
      schema:
        type: string
        format: date-time
      example: "Sun, 06 Jul 2026 08:49:37 GMT"

  headers:
    ETag:
      description: |
        Strong validator (quoted hex string) for this exact response body.
        Echo it back as `If-None-Match` on the next request to this URL to
        get a `304` instead of re-downloading the body.
      schema:
        type: string
      example: '"a1b2c3d4e5f60718"'

    LastModified:
      description: |
        When this Worker cached the representation being returned - not
        when the underlying data last changed (recomputed on every
        pipeline tick, so it moves even on a byte-identical body). Echo it
        back as `If-Modified-Since` to get a `304` while that cache entry
        is still alive. `ETag` / `If-None-Match` is the stronger, recommended
        validator; see its description for the RFC 7232 section 6
        precedence rule.
      schema:
        type: string
      example: "Sun, 06 Jul 2026 08:49:37 GMT"

    FeedStale:
      description: |
        Mirrors the top-level `stale` boolean from the `/v1/status` body
        as a string, so a client can branch on the verdict without
        parsing JSON.
      schema:
        type: string
        enum: ["true", "false"]
      example: "false"

    FeedAgeSeconds:
      description: |
        Mirrors the top-level `age_seconds` from the `/v1/status` body
        (seconds since the pipeline document was generated). May be
        empty if the age could not be computed.
      schema:
        type: string
      example: "122"

    ResultCount:
      description: Number of rows in this response.
      schema:
        type: integer
      example: 10000

    ResultTruncated:
      description: |
        Present, with the value `true`, only when the 10 000-row cap was
        reached and rows were dropped. Absent means you received the whole
        window. It is never sent as `false`, so test for presence.
      schema:
        type: string
        enum: ["true"]
      example: "true"

    ResultWindowStart:
      description: |
        Date of the OLDEST row in this response. When `X-Result-Truncated` is
        present on a `/v1/{time}` call this is later than the window you asked
        for, and the difference is exactly what the cap dropped.
      schema:
        type: string
      example: "2026-07-11 21:34:00"

    ResultWindowEnd:
      description: |
        Date of the NEWEST row in this response. On a truncated
        `/v1/since/{datetime}` call this is where to resume: pass it back as
        the next `since` value to page forward through the remainder.
      schema:
        type: string
      example: "2026-07-30 17:00:00"

    ResultWindowIncomplete:
      description: |
        Present, with the value `true`, only on `/v1/since` and only when the
        file backing the response does not reach as far back as the `since`
        you asked for. In practice that means a `since` older than 30 days:
        the endpoint is served from the 30-day window file, so it answers with
        those 30 days rather than failing. Pair it with `X-Result-Window-Start`
        to see where the data actually begins, and fetch
        `https://tweetfeed.live/feeds/year.csv` for the rest.

        The history is not missing - it exists for 365 days, it just cannot be
        scanned inside the Worker's CPU budget on this route.

        Different from `X-Result-Truncated`: `truncated` means the 10 000-row
        cap cut rows out of a window that was served in full; this one means
        the served window itself starts later than you asked. They are
        independent and can both appear on the same response. Like its
        siblings it is never sent as `false`, so test for presence.
      schema:
        type: string
        enum: ["true"]
      example: "true"

  responses:
    NotModified:
      description: |
        The client's cached copy is still current (matched via
        `If-None-Match` or `If-Modified-Since`). No body is returned.
      headers:
        ETag:
          $ref: "#/components/headers/ETag"
        Last-Modified:
          $ref: "#/components/headers/LastModified"

    DefangInvalid:
      description: |
        `defang` was present but not `1`, `true`, `0` or `false`.
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error:
                type: string
          example:
            error: "defang must be 1 or 0"

  schemas:
    Ioc:
      type: object
      required: [date, user, type, value, tags, tweet]
      properties:
        date:
          type: string
          description: |
            Publication timestamp of the tweet that reported the IOC, UTC,
            format `YYYY-MM-DD HH:MM:SS`. It is the tweet's own publication
            time as given by the source feed, not the moment the pipeline
            ingested it (the pipeline polls every 15 minutes, so the two are
            normally minutes apart).

            When the reporting tweet is a quote-tweet, the IOC is extracted
            from the QUOTED tweet but stamped with the QUOTING tweet's time,
            so that a re-share lands in the day it was re-shared. `user` and
            `tweet` then point at the quoted tweet while `date` points at
            the quoting one, and `date` can be much newer than the tweet the
            IOC first appeared in.
          example: "2026-05-21 09:13:00"
        user:
          type: string
          description: Twitter/X handle of the reporting researcher (no leading `@`).
          example: "malwrhunterteam"
        type:
          type: string
          enum: [url, domain, ip, sha256, md5]
          description: IOC type.
        value:
          type: string
          description: |
            The IOC value itself. URLs/domains are emitted refanged (the original
            tweet may have defanged them with `[.]` or `hxxp` - the pipeline
            normalises to refanged form on emit). File hashes are lowercase hex.
            Defanged instead when the request carries `?defang=1`; hashes are
            unaffected either way (no `.` to defang).
          example: "http://example.malicious/path"
        tags:
          type: array
          items:
            type: string
          description: |
            Tag labels (typically include the IOC category - `phishing`, `malware`,
            `ransomware`, etc. - plus malware-family or threat-actor labels when
            applicable). Current taxonomy: 94 tags. Full list at
            https://tweetfeed.live/tags/.
          example: ["phishing", "scam"]
        tweet:
          type: string
          format: uri
          description: URL of the tweet that reported the IOC.
          example: "https://x.com/malwrhunterteam/status/1234567890123456789"
        ai:
          type: object
          nullable: true
          description: |
            Optional per-IOC AI enrichment, mirroring the `ai` object `GET
            /v1/ioc` returns. Present only once the value's own enrichment
            job has covered it; absent (not `null`) otherwise.
          properties:
            threat_type:
              type: string
              example: "phishing"
            family:
              type: string
              description: Malware family, when identified. Omitted when unknown.
              example: "Joker"
        net:
          type: object
          nullable: true
          description: |
            Optional per-IOC network metadata, mirroring the `net` object
            `GET /v1/ioc` returns. `ip`-type IOCs only.
          properties:
            org:
              type: string
              example: "AS13335 Cloudflare, Inc."
            country:
              type: string
              example: "US"

    IocRecord:
      type: object
      required: [type, value, first_seen, last_seen, count, users, tags, tweets]
      properties:
        type:
          type: string
          enum: [url, domain, ip, sha256, md5]
          description: IOC type.
        value:
          type: string
          description: |
            The IOC value, refanged (same normalisation as the main feed).
            Defanged instead when the request carries `?defang=1`.
        first_seen:
          type: string
          format: date-time
          description: UTC timestamp of the earliest occurrence within the 365-day window.
        last_seen:
          type: string
          format: date-time
          description: UTC timestamp of the most recent occurrence within the 365-day window.
        count:
          type: integer
          description: Total number of times this IOC was reported within the window.
        users:
          type: array
          maxItems: 10
          items:
            type: string
          description: Reporting researcher handles (no leading `@`), capped at 10.
        tags:
          type: array
          maxItems: 10
          items:
            type: string
          description: Distinct tags observed across all reports of this IOC, capped at 10.
        tweets:
          type: array
          maxItems: 3
          items:
            type: string
          description: Sample source tweet URLs, capped at 3.
        related:
          type: array
          items:
            type: array
            minItems: 2
            maxItems: 2
            items:
              type: string
            description: |
              A `[type, value]` pair; `value` (index 1) is defanged too
              when the request carries `?defang=1`.
          description: |
            Optional. Other IOCs posted in the same source tweets as this one
            (sample, max 5). Absent when there are none.

    IocArchiveRecord:
      type: object
      required: [type, value, first_seen, last_seen, count, tweet_id, tags]
      description: |
        A single hit from the archive index. Deliberately a reduced schema
        versus IocRecord - no `users`, `tweets` or `related` - to keep the
        4096-shard archive index small.
      properties:
        type:
          type: string
          enum: [url, domain, ip, sha256, md5]
          description: IOC type.
        value:
          type: string
          description: |
            The IOC value, refanged (same normalisation as the main feed).
            Defanged instead when the request carries `?defang=1`.
        first_seen:
          type: string
          format: date-time
          description: UTC timestamp of the earliest archived occurrence.
        last_seen:
          type: string
          format: date-time
          description: UTC timestamp of the most recent archived occurrence.
        count:
          type: integer
          description: Total number of times this IOC was reported in the archive.
        tweet_id:
          type: string
          description: |
            Numeric id of the tweet that produced `first_seen`; rebuild the
            URL as `https://x.com/i/status/<tweet_id>`. Can be an empty
            string.
        tags:
          type: array
          maxItems: 3
          items:
            type: string
          description: Up to 3 tags, keeping their leading `#`.

    IocLookupResult:
      type: object
      required: [found, query, window, records]
      properties:
        found:
          type: boolean
          description: |
            Whether at least one matching record was found in the live
            365-day window. Independent of the optional `archive` block -
            `found: false` does not mean the value is absent from `archive`.
        query:
          type: string
          description: |
            The normalised (refanged) value that was looked up. Defanged
            instead when the request carries `?defang=1`.
        window:
          type: string
          description: |
            Retention window searched for `records`. The optional `archive`
            block, when present, covers everything older.
          example: "365d"
        records:
          type: array
          items:
            $ref: "#/components/schemas/IocRecord"
        ai:
          type: object
          description: |
            Optional. AI-generated enrichment sidecar for this IOC - not part
            of the canonical feed data, present only once an enrichment run
            has annotated this value.
          properties:
            summary:
              type: string
              description: |
                AI-generated one-line summary of the IOC's context
                (<=200 chars). Never defanged, even with `?defang=1` -
                it is prose, not a field, and may still name a live
                hostname.
            family:
              type: string
              nullable: true
              description: Malware family, when identifiable. `null` otherwise.
            threat_type:
              type: string
              description: AI-classified threat category.
            suggested_tags:
              type: array
              items:
                type: string
              description: AI-suggested tags drawn from the feed's tag vocabulary.
            confidence:
              type: number
              description: AI confidence score, 0-1.
            enriched_at:
              type: string
              format: date-time
              description: UTC timestamp of the enrichment run.
        external:
          type: array
          maxItems: 5
          description: |
            Optional. Cross-feed corroboration from public threat feeds -
            three abuse.ch feeds (URLhaus / ThreatFox / MalwareBazaar) plus
            two flat blocklists (USOM / IPsum); absent when no match or the
            sidecar is unavailable. `link` and `added` are present only for
            sources that publish them (abuse.ch); USOM/IPsum entries carry
            only `src`/`threat`.
          items:
            type: object
            required: [src, threat]
            properties:
              src:
                type: string
                enum: [urlhaus, threatfox, malwarebazaar, usom, ipsum]
                description: Which feed reported this indicator.
              threat:
                type: string
                description: Threat label as reported by the source feed.
              link:
                type: string
                format: uri
                description: |
                  URL of the source feed's entry for this indicator.
                  Present only for urlhaus/threatfox/malwarebazaar.
              added:
                type: string
                format: date
                description: |
                  Date the indicator was added to the source feed. Present
                  only for urlhaus/threatfox/malwarebazaar.
        external_exclusive:
          type: boolean
          description: |
            Optional. `false` when `external` has at least one entry;
            `true` when the cross-check resolved and none of the 5 tracked
            feeds (URLhaus, ThreatFox, MalwareBazaar, USOM, IPsum) lists
            this value; absent when the cross-check could not be resolved
            (unknown) or the value was not found. Set membership only - not
            a quality score and not a verdict.
        net:
          type: object
          description: |
            IP network metadata from ipinfo.io (third-party sidecar,
            refreshed every 6h); absent for non-IP lookups or when
            unavailable.
          properties:
            org:
              type: string
              description: Organisation/ASN name as reported by ipinfo.io.
            country:
              type: string
              description: ISO country code.
            city:
              type: string
              description: City name.
            fetched_at:
              type: string
              format: date-time
              description: UTC timestamp the network metadata was last refreshed.
            bogon:
              type: boolean
              description: |
                `true` when the looked-up value is a bogon/reserved-range
                address. Other fields are absent in that case.
            drop:
              type: object
              description: |
                Optional. Present only when this IP's network has a hit in
                The Spamhaus Project's DROP/ASN-DROP netblock lists (a
                droplet-side sidecar refresh, independent of ipinfo.io
                above); absent means no hit was found, never "verified
                clean". `cidr` and `asn` are two independent checks - either,
                both, or neither may be present. A hit flags the NETWORK
                hosting this IP, not the IOC value itself.
              properties:
                cidr:
                  type: object
                  description: |
                    Present when the IP falls inside a netblock listed on
                    Spamhaus's DROP (Don't Route Or Peer) list.
                  properties:
                    sblid:
                      type: string
                      description: |
                        Spamhaus SBL reference id for the listed netblock,
                        e.g. "SBL627127".
                asn:
                  type: object
                  description: |
                    Present when this IP's ASN is listed on Spamhaus's
                    ASN-DROP list. Carries no sblid - ASN-DROP entries are
                    referenced by ASN, not by SBL id.
                  properties:
                    asn:
                      type: integer
                      description: The flagged ASN number.
                    asname:
                      type: string
                      description: |
                        AS name as published by Spamhaus. May be absent even
                        when the asn block itself is present.
            range:
              type: object
              description: |
                Optional. The official published IP range of a known
                provider (for example AWS EC2 or GitHub Actions) that
                contains this IP. It describes the network, not the IOC,
                and it is not a verdict. Absent means no known range
                matched.
              properties:
                provider:
                  type: string
                  description: Provider name, e.g. "GitHub".
                service:
                  type: string
                  description: Provider service the range belongs to, e.g. "actions".
                region:
                  type: string
                  description: Provider region. Empty when the list gives none.
                kind:
                  type: string
                  enum: [bot, user_fetcher, relay, mail, service, platform, hosting]
                  description: |
                    What the range is: `bot` (crawler or monitor run by the
                    provider), `user_fetcher` (fetches pages for any user of
                    the provider), `relay` (privacy relay or proxy egress),
                    `mail` (mail sending service), `service` (provider service
                    infrastructure), `platform` (shared platform that serves
                    customer content), `hosting` (rentable cloud hosting).
                source:
                  type: string
                  description: Id of the official list the range comes from.
                prefix:
                  type: string
                  description: The published CIDR prefix that contains this IP.
                  example: "172.168.0.0/15"
                also:
                  type: array
                  maxItems: 3
                  description: Optional. Other official ranges that also contain this IP.
                  items:
                    type: object
                    properties:
                      provider:
                        type: string
                      service:
                        type: string
                      kind:
                        type: string
                      source:
                        type: string
        reg:
          type: object
          description: |
            Optional. Domain registration enrichment: RDAP registrar/
            creation-date/nameservers plus the IP(s)/ASN the domain resolved
            to at first-seen. Covers domain/url values seen in the 30-day
            window only; absent when the value hasn't been enriched yet or
            falls outside that coverage window.
          properties:
            apex:
              type: string
              description: |
                Registered/apex domain the lookup value resolves under.
                Defanged instead when the request carries `?defang=1`.
            registrar:
              type: string
              nullable: true
              description: Registrar name from RDAP. `null` when unavailable.
            created:
              type: string
              nullable: true
              description: Domain creation date from RDAP. `null` when unavailable.
            age_days_at_report:
              type: integer
              minimum: 0
              description: |
                Optional. UTC calendar days between `created` and the earliest
                `first_seen` TweetFeed has for this value (live window and
                archive). Omitted when `created` is unavailable or when the
                domain was registered more than 24h after the first report.
                `0` = registered the same day it was first reported.
            newly_registered:
              type: boolean
              description: |
                Optional. `true` when `age_days_at_report` is 30 or less
                (same threshold as phishunt.io). Present only alongside
                `age_days_at_report`.
            ns:
              type: array
              nullable: true
              items:
                type: string
              description: |
                Nameservers from RDAP. `null` when unavailable. Defanged
                (dots only - nameservers carry no scheme) instead when the
                request carries `?defang=1`.
            ips:
              type: array
              nullable: true
              items:
                type: string
              description: |
                IP addresses the domain resolved to at first-seen. `null`
                when unavailable. Defanged instead when the request
                carries `?defang=1`.
            asn:
              type: string
              nullable: true
              description: ASN of the resolved IP(s) at first-seen. `null` when unavailable.
            org:
              type: string
              nullable: true
              description: Organisation name for the ASN. `null` when unavailable.
            status:
              type: string
              enum: [ok, nxdomain, tld_unsupported]
              description: |
                `ok` = RDAP registration data found; `nxdomain` = the domain was
                already deleted from the registry at first check; `tld_unsupported` =
                the TLD's registry exposes no RDAP (registration fields are null,
                but resolved IPs/ASN may still be present).
            checked_at:
              type: string
              format: date-time
              description: UTC timestamp the registration/DNS data was checked.
        campaigns:
          type: array
          maxItems: 3
          description: |
            Optional. Published campaigns this IOC belongs to, newest
            `last_seen` first (max 3). Present only when the IOC belongs to
            at least one published campaign, independent of `found` - a
            value can be in a campaign while missing from the live 365-day
            window (see `archive`). This is a small sidecar reference, not
            the full campaign document - see `Campaign` for `context`,
            `targeted_brand` and the rest.
          items:
            type: object
            required: [id, name, confidence, threat_types, ioc_count, last_seen]
            properties:
              id:
                type: string
                description: Stable campaign identifier - `tfc-` followed by 12 hex chars.
                example: "tfc-6d1f6fb9260c"
              name:
                type: string
                description: Short AI-generated campaign name.
              confidence:
                type: string
                enum: [high, medium, low]
                description: How strongly related the campaign's underlying IOCs are.
              threat_types:
                type: array
                items:
                  type: string
                description: AI-inferred threat types seen across the campaign.
                example: ["phishing"]
              ioc_count:
                type: integer
                description: Total IOCs in the campaign.
                example: 198
              last_seen:
                type: string
                description: UTC timestamp of the campaign's most recent IOC.
                example: "2026-09-05 14:30:00"
        archive:
          type: object
          description: |
            Optional. Hits in the archive (everything published before the
            365-day window, back to `first_date` in
            `TweetFeed/archive/meta.json` - the coverage start moves daily);
            absent when there is no archive match. Additive to `found` and
            `records`, never merged into them - `found: true` and `archive`
            can both be present for the same value at once (their date
            ranges are disjoint by construction); never sum `archive.total`
            with the live-window `count`.
          properties:
            window:
              type: string
              description: Archive window searched.
              example: "pre-365d"
            total:
              type: integer
              description: Sum of `count` across `records`.
            records:
              type: array
              items:
                $ref: "#/components/schemas/IocArchiveRecord"

    TrendsDocument:
      type: object
      description: |
        Trends analytics document. Loosely typed here - see
        https://tweetfeed.live/trends/ for the human-readable rendering.
      properties:
        daily:
          type: object
          description: |
            31 days of totals plus a per-IOC-type breakdown (url/domain/ip/
            sha256/md5), used for the rolling 30-day volume chart.
        movers:
          type: object
          description: Current vs previous 7-day tag counts, ranked by change magnitude.
        tlds:
          type: object
          description: Top TLDs among malicious domains from the last 30 days.
        novelty:
          type: object
          description: |
            Share of this week's distinct IOC values that are new versus
            recurring.

    ContributorsDocument:
      type: object
      required: [schema_version, generated_at, windows]
      description: |
        Per-window reporter ranking document. Powers
        https://tweetfeed.live/researchers/ - today/week/month/year rankings
        of X/Twitter handles by IOCs reported, precomputed server-side every
        15 minutes so the page renders four windows from one fetch instead
        of ranking CSVs in the browser.
      properties:
        schema_version:
          type: integer
          example: 1
        generated_at:
          type: string
          format: date-time
          description: UTC timestamp this document was generated, `YYYY-MM-DDTHH:MM:SSZ`.
          example: "2026-09-12T16:30:00Z"
        windows:
          type: object
          description: Keyed by window name - `today`, `week`, `month`, `year`.
          properties:
            today:
              $ref: "#/components/schemas/ContributorsWindow"
            week:
              $ref: "#/components/schemas/ContributorsWindow"
            month:
              $ref: "#/components/schemas/ContributorsWindow"
            year:
              $ref: "#/components/schemas/ContributorsWindow"

    ContributorsWindow:
      type: object
      required: [first_date, last_date, rows, users, entries]
      description: |
        One ranking window. An empty window (no IOCs reported in that
        period) has `rows: 0`, `users: 0`, `entries: []` and both dates null.
      properties:
        first_date:
          type: [string, "null"]
          description: Timestamp of the oldest row in this window, `YYYY-MM-DD HH:MM:SS` UTC.
          example: "2026-09-12 00:01:00"
        last_date:
          type: [string, "null"]
          description: Timestamp of the newest row in this window, `YYYY-MM-DD HH:MM:SS` UTC.
          example: "2026-09-12 16:15:00"
        rows:
          type: integer
          description: Total IOC rows in this window.
        users:
          type: integer
          description: Distinct reporting handles in this window.
        entries:
          type: array
          description: Ranked by `iocs` descending, tie-broken by handle ascending.
          items:
            $ref: "#/components/schemas/ContributorEntry"

    ContributorEntry:
      type: object
      required: [user, iocs, top_types, top_tags, first_seen, last_seen]
      properties:
        user:
          type: string
          description: X/Twitter handle, merged case-insensitively.
          example: "TKemmerling"
        iocs:
          type: integer
          description: IOCs reported by this handle within the window.
          example: 6
        top_types:
          type: array
          maxItems: 3
          items:
            type: string
          description: Top IOC types reported, most frequent first (3 max).
          example: ["url", "domain"]
        top_tags:
          type: array
          maxItems: 3
          items:
            type: string
          description: Top tags reported, most frequent first (3 max), without a leading `#`.
          example: ["phishing", "scam"]
        first_seen:
          type: string
          description: Timestamp of this handle's oldest row in the window, `YYYY-MM-DD HH:MM:SS` UTC.
          example: "2026-09-12 07:10:00"
        last_seen:
          type: string
          description: Timestamp of this handle's newest row in the window, `YYYY-MM-DD HH:MM:SS` UTC.
          example: "2026-09-12 15:45:00"

    CountsDocument:
      type: object
      required: [generated_at, windows]
      properties:
        generated_at:
          type: string
          format: date-time
          description: UTC timestamp this document was generated, `YYYY-MM-DDTHH:MM:SSZ`.
          example: "2026-07-29T18:15:03Z"
        windows:
          type: object
          required: [today, week, month, year]
          description: One entry per standard time window.
          properties:
            today:
              $ref: "#/components/schemas/CountsWindow"
            week:
              $ref: "#/components/schemas/CountsWindow"
            month:
              $ref: "#/components/schemas/CountsWindow"
            year:
              allOf:
                - $ref: "#/components/schemas/CountsWindow"
                - type: object
                  required: [months]
                  properties:
                    months:
                      type: array
                      minItems: 12
                      maxItems: 12
                      items:
                        type: integer
                      description: |
                        IOC count per calendar month, only present on `year`.
                        Indexed by calendar month, NOT relative to the window:
                        index `0` is always January, index `11` is always
                        December, regardless of which month the document was
                        generated in.
                      example: [1203, 980, 1450, 1102, 1330, 1560, 1290, 0, 0, 0, 0, 0]

    CountsWindow:
      type: object
      required: [total, types, tags, first_date, last_date]
      properties:
        total:
          type: integer
          description: Total IOC count in this window.
        types:
          type: object
          description: IOC count per type (`url`, `domain`, `ip`, `sha256`, `md5`).
          additionalProperties:
            type: integer
          example: {"url": 812, "domain": 301, "ip": 90}
        tags:
          type: object
          description: |
            IOC count per tag. Keys are lowercase and have no leading `#`
            (unlike the `tags` array on `Ioc`, which keeps the `#` prefix).
          additionalProperties:
            type: integer
          example: {"phishing": 640, "malware": 210}
        first_date:
          type: string
          nullable: true
          description: |
            Timestamp of the earliest IOC in this window, `YYYY-MM-DD
            HH:MM:SS`. `null` when the window has no IOCs.
          example: "2026-07-28 00:04:11"
        last_date:
          type: string
          nullable: true
          description: |
            Timestamp of the most recent IOC in this window, `YYYY-MM-DD
            HH:MM:SS`. `null` when the window has no IOCs.
          example: "2026-07-29 18:11:47"

    CampaignsDocument:
      type: object
      required: [version, generated_at, window, stale, campaign_count, campaigns]
      properties:
        version:
          type: integer
          description: Schema version of the campaigns document. Currently `1`.
          example: 1
        generated_at:
          type: string
          format: date-time
          description: UTC timestamp of the clustering run that produced this document.
          example: "2026-07-05T19:36:35Z"
        window:
          type: string
          enum: [month]
          description: Source window for clustering - always the rolling last 30 days.
        stale:
          type: boolean
          description: |
            `true` if the daily clustering run failed and this document was
            carried over from the previous successful run.
        stale_since:
          type: string
          nullable: true
          format: date-time
          description: |
            Timestamp (`generated_at`) of the last successful run before
            staleness began. Only set when `stale` is `true`, `null`
            otherwise.
          example: null
        campaign_count:
          type: integer
          description: Number of entries in `campaigns`.
          example: 23
        campaigns:
          type: array
          items:
            $ref: "#/components/schemas/Campaign"

    CampaignIocsDocument:
      type: object
      required: [version, generated_at, campaigns]
      properties:
        version:
          type: integer
          description: Schema version of the campaign IOCs document. Currently `1`.
          example: 1
        generated_at:
          type: string
          format: date-time
          description: UTC timestamp of the clustering run that produced this document.
          example: "2026-09-04T06:56:16Z"
        campaigns:
          type: object
          description: |
            Full IOC membership, keyed by campaign id (matching `/v1/campaigns`'
            `id`). Unlike `Campaign.iocs`, which caps at a 25-row sample, each
            value here is the complete, unsampled list.
          additionalProperties:
            type: array
            items:
              $ref: "#/components/schemas/Ioc"
          example:
            tfc-0eebc91ceb12:
              - date: "2026-08-25 13:42:00"
                tags: ["#stealer"]
                tweet: "https://x.com/suyog41/status/2092246114179432584"
                type: "domain"
                user: "suyog41"
                value: "lewokodwqko.icu"

    Campaign:
      type: object
      required:
        [id, name, context, confidence, targeted_brand, targeted_sector, targeted_country,
         first_seen, last_seen, ioc_count, types, tags, reporters, iocs,
         member_cluster_ids, anchors]
      properties:
        id:
          type: string
          description: Stable campaign identifier - `tfc-` followed by 12 hex chars.
          example: "tfc-6d1f6fb9260c"
        name:
          type: string
          description: Short AI-generated campaign name.
          example: "Multi-family RAT/stealer C2 on compromised and DDNS hosts"
        context:
          type: string
          description: |
            AI-generated paragraph describing the campaign's infrastructure,
            malware families and delivery pattern. Describes the underlying
            IOCs only - never introduces facts absent from the data.
        confidence:
          type: string
          enum: [high, medium, low]
          description: |
            How strongly related the underlying IOCs are, based on shared
            anchors (domains, path patterns, tags) and reporter overlap.
        targeted_brand:
          type: string
          nullable: true
          description: Brand or organization the campaign appears to target, when identifiable. `null` otherwise.
        targeted_sector:
          type: string
          nullable: true
          description: |
            Industry sector the campaign appears to target, AI-inferred from
            the clustered IOCs. Values are STIX 2.1 `industry-sector-ov` slugs
            (e.g. `financial-services`). `null` when no sector is identifiable.
          example: "financial-services"
        targeted_country:
          type: string
          nullable: true
          description: |
            Country the campaign appears to target, AI-inferred from the
            clustered IOCs. ISO 3166-1 alpha-2. `null` when no country is
            identifiable.
          example: "BR"
        ttps:
          type: array
          maxItems: 4
          items:
            type: string
            enum:
              - T1036.005
              - T1071.001
              - T1102
              - T1195.001
              - T1204.004
              - T1557
              - T1566.002
              - T1567.004
              - T1568
              - T1583.001
              - T1583.006
              - T1583.008
              - T1585.001
              - T1588.001
              - T1588.002
              - T1608.001
              - T1657
              - T1684.001
          description: |
            MITRE ATT&CK Enterprise technique ids the campaign's
            infrastructure appears to exercise, AI-inferred from the
            clustered IOCs - infrastructure-only, not full attribution.
            Always an array with 0 to 4 ids, never `null`; may be an empty
            array. May be absent entirely on an older cached document.
          example: ["T1566.002", "T1583.006"]
        first_seen:
          type: string
          format: date
          example: "2026-06-29"
        last_seen:
          type: string
          format: date
          example: "2026-07-05"
        ioc_count:
          type: integer
          description: Total IOCs in the cluster (may exceed the `iocs` sample size).
          example: 198
        ioc_count_1d:
          type: integer
          description: |
            IOCs in the cluster observed in the last 1 day. Per-window activity
            counts satisfy `ioc_count_1d <= ioc_count_7d <= ioc_count_30d <= ioc_count`.
          example: 4
        ioc_count_7d:
          type: integer
          description: |
            IOCs in the cluster observed in the last 7 days. Consumers filtering
            for live campaigns should use `ioc_count_7d > 0`.
          example: 22
        ioc_count_30d:
          type: integer
          description: IOCs in the cluster observed in the last 30 days.
          example: 87
        types:
          type: object
          description: Per-IOC-type counts across the cluster.
          additionalProperties:
            type: integer
          example: {"domain": 53, "ip": 32, "md5": 17, "sha256": 15, "url": 81}
        activity:
          type: object
          description: |
            Optional sparse per-day IOC histogram, chronologically sorted
            keys (`YYYY-MM-DD`), counting every row in the campaign - not
            just the published `iocs` sample. May be absent entirely on an
            older cached or stale-fallback document.
          additionalProperties:
            type: integer
          example: {"2026-08-14": 205, "2026-08-19": 202}
        enriched_count:
          type: integer
          description: |
            Optional: how many of the campaign's IOCs (across the full
            membership, not just the sample) have an AI enrichment entry.
            The denominator for `threat_types` and `families` below. May be
            absent entirely on an older cached document.
          example: 182
        threat_types:
          type: object
          description: |
            Optional rollup of AI-inferred threat types over the campaign's
            full membership (not just the sample), keyed by type with a
            count. May be absent entirely on an older cached document.
          additionalProperties:
            type: integer
          example: {"phishing": 172, "cryptoscam": 100}
        families:
          type: object
          description: |
            Optional rollup of AI-inferred malware families over the
            campaign's full membership (not just the sample), keyed by
            family name with a count. Usually empty or absent since family
            attribution is sparse. May be absent entirely on an older
            cached document.
          additionalProperties:
            type: integer
          example: {"Joker": 10}
        infra:
          type: array
          description: |
            Optional build-time ASN join, present only when the campaign
            has at least one `ip`-type IOC. Sorted by `ip_count` descending.
            The ASN is embedded in `org` - there is no separate `asn`
            field. May be absent entirely on an older cached document or on
            a campaign with no IP IOCs.
          items:
            type: object
            properties:
              org:
                type: string
                example: "AS13335 Cloudflare, Inc."
              ip_count:
                type: integer
                example: 3
              country:
                type: string
                example: "US"
        tags:
          type: array
          items:
            type: string
          description: Tags observed across the cluster's IOCs.
        reporters:
          type: array
          items:
            type: string
          description: Twitter/X handles (no leading `@`) that reported IOCs in this cluster.
        iocs:
          type: array
          maxItems: 25
          items:
            $ref: "#/components/schemas/Ioc"
          description: Sample of up to 25 member IOCs, verbatim from the feed (same shape as `/v1/{time}`).
        member_cluster_ids:
          type: array
          items:
            type: string
          description: IDs of the deterministic pre-grouping clusters merged into this campaign.
        anchors:
          type: object
          description: Deterministic pre-grouping signals used before AI naming.
          properties:
            registered_domains:
              type: array
              items:
                type: string
            url_path_patterns:
              type: array
              items:
                type: string
            tags:
              type: array
              items:
                type: string
            families:
              type: array
              items:
                type: string
              description: |
                Optional, only on an orphan hash/IP bucket: the malware
                family local enrichment attributed those IOCs to. Such a
                bucket has no domain/path/tag anchor - the shared family is
                what makes it one campaign. Absent everywhere else.
              example: ["Lumma"]
        patterns:
          type: array
          maxItems: 3
          description: |
            Optional naming patterns: deterministic regexes generalised from
            the campaign's own registered domains (shared literal prefix and
            suffix, one variable piece), each with evidence counts. Omitted
            when no pattern passes the gates. Currently shadow upstream, so
            absent on most documents. Attacker-derived text - treat as data.
          items:
            type: object
            properties:
              regex:
                type: string
                example: "^telegram[a-z]{2,5}\\.top$"
              domain_count:
                type: integer
                description: Own registered domains the regex matches.
              ioc_count:
                type: integer
                description: Own rows whose registered domain matches.
              domains_elsewhere_30d:
                type: integer
                description: Unowned 30-day feed domains the regex also matches (leakage gate).
              examples:
                type: array
                maxItems: 3
                items:
                  type: string
              first_seen:
                type: string
                format: date
                nullable: true
              last_seen:
                type: string
                format: date
                nullable: true
        history:
          type: object
          description: |
            Optional 365-day evidence behind the 30-day card: a streaming
            pass over the public year.csv attributing rows to this campaign
            by exact registered domain or by one of its `patterns`. Omitted
            when the yearly scan failed or timed out. Dates are `YYYY-MM-DD`.
          properties:
            window_days:
              type: integer
              example: 365
            first_seen_365d:
              type: string
              format: date
            last_seen_365d:
              type: string
              format: date
            domains_365d:
              type: integer
            iocs_365d:
              type: integer
            iocs_before_window:
              type: integer
              description: Attributed rows that predate the 30-day window.
            by_pattern:
              type: array
              items:
                type: object
                properties:
                  regex:
                    type: string
                  domains_365d:
                    type: integer
                  first_seen_365d:
                    type: string
                    format: date
        related:
          type: object
          description: |
            Optional cross-feed corroboration: phishunt.io detections active
            at build time whose registered domain is one of the campaign's
            (`exact`) or matches one of its `patterns` (`pattern`). Never
            members, never in `ioc_count`. Omitted when nothing matches or
            the fetch failed. `matches` is capped at 10; `match_count` keeps
            the uncapped total.
          properties:
            source:
              type: string
              example: phishunt.io
            checked_at:
              type: string
              format: date-time
            match_count:
              type: integer
            matches:
              type: array
              maxItems: 10
              items:
                type: object
                properties:
                  domain:
                    type: string
                  match:
                    type: string
                    enum: [exact, pattern]
                  company:
                    type: string
                    nullable: true
                    description: Brand phishunt.io attributed the detection to.
                  asn:
                    type: string
                    nullable: true
                    example: AS60631

    CampaignExport:
      type: object
      required: [generated_at, campaign, iocs]
      properties:
        generated_at:
          type: string
          format: date-time
          description: UTC timestamp of the clustering run that produced this document.
          example: "2026-09-06T06:56:16Z"
        campaign:
          $ref: "#/components/schemas/Campaign"
        iocs:
          type: array
          items:
            $ref: "#/components/schemas/Ioc"
          description: |
            Complete, unsampled IOC membership - unlike `campaign.iocs`,
            which caps at a 25-row sample.

    StatusDocument:
      type: object
      required: [schema_version, generated_at, pipeline, artifacts, checked_at, age_seconds, stale, any_stale]
      description: |
        Freshness facts for every published artifact plus a per-request
        verdict. Computed fresh on every request - see the `/v1/status`
        path description for caching/conditional-request notes.
      properties:
        schema_version:
          type: integer
          example: 1
        generated_at:
          type: string
          format: date-time
          description: UTC timestamp the underlying `status.json` document was generated.
          example: "2026-09-05T14:45:56Z"
        pipeline:
          type: object
          description: The main 15-minute pipeline's own cadence.
          properties:
            interval_seconds:
              type: integer
              example: 900
            stale_after_seconds:
              type: integer
              example: 1800
        contract:
          type: object
          description: Static licensing/retention facts, unrelated to freshness.
          additionalProperties: true
          example:
            license: CC0-1.0
            tlp: CLEAR
            live_window_days: 365
            archive: all history
            docs: "https://tweetfeed.live/api/"
            data_repo: "https://github.com/0xDanielLopez/TweetFeed"
        artifacts:
          type: object
          description: |
            One entry per published artifact (`today.csv`, `week.csv`,
            `month.csv`, `year.csv`, `rss.xml`, `counts.json`, `misp`,
            `stix`, `blocklists`, `exclusive.csv`, `lookup`, `trends.json`,
            `contributors.json`, `archive`, `external.json`, `ipmeta.json`,
            `enrichment.json`, `campaigns.json`, `domainmeta.json`), keyed
            by artifact name.
          additionalProperties:
            $ref: "#/components/schemas/StatusArtifact"
        checked_at:
          type: string
          format: date-time
          description: UTC timestamp this specific request was evaluated at.
          example: "2026-09-05T14:47:58Z"
        age_seconds:
          type: integer
          nullable: true
          description: Seconds since `generated_at` (top-level pipeline age).
          example: 122
        stale:
          type: boolean
          description: |
            Top-level verdict: `true` if the pipeline document itself is
            older than the pipeline's `stale_after_seconds`. Mirrored in
            the `X-Feed-Stale` response header.
        any_stale:
          type: boolean
          description: |
            `true` if ANY single artifact entry is stale, even while the
            top-level `stale` is `false`.
        sources:
          type: object
          description: |
            Per-X-source coverage: one entry per enabled hashtag/account
            feed. The backend writes the facts (`newest_item_at`,
            `rows_last_24h`); `age_seconds`, `dead`, `dead_ids`, `any_dead`
            and `with_rows_last_24h` are verdicts computed fresh on every
            request, like the top-level `stale`. Hashtags never get a
            `dead` verdict (many tags are legitimately quiet for weeks) -
            their `dead_after_seconds`/`dead` are always `null`. A single
            IOC row tagged with two sources counts toward both; block-level
            `rows_last_24h` counts distinct rows. Does not affect `stale`/
            `any_stale` or the `x-feed-*` headers.
          required: [total, hashtag, account, rows_last_24h, dead_after_seconds, with_rows_last_24h, dead, dead_ids, any_dead, items]
          properties:
            total:
              type: integer
              description: Total enabled X source feeds (hashtag + account).
              example: 47
            hashtag:
              type: integer
              example: 38
            account:
              type: integer
              example: 9
            rows_last_24h:
              type: integer
              description: Distinct IOC rows attributed to any source in the last 24h.
              example: 105
            dead_after_seconds:
              type: object
              description: Dead-verdict threshold per source kind (`null` = no verdict).
              properties:
                account:
                  type: [integer, "null"]
                  example: 259200
                hashtag:
                  type: [integer, "null"]
                  example: null
            with_rows_last_24h:
              type: integer
              description: Sources with at least one attributed row in the last 24h.
              example: 31
            dead:
              type: integer
              description: Count of account sources older than `dead_after_seconds`.
              example: 3
            dead_ids:
              type: array
              items:
                type: string
              example: ["500mk500", "byrne_emmy12099", "Fact_Finder03"]
            any_dead:
              type: boolean
              example: true
            items:
              type: array
              items:
                type: object
                required: [id, kind, newest_item_at, rows_last_24h, dead_after_seconds, age_seconds, dead]
                properties:
                  id:
                    type: string
                    description: The tag or handle, without `#`/`@`.
                    example: phishing
                  kind:
                    type: string
                    enum: [hashtag, account]
                  newest_item_at:
                    type: [string, "null"]
                    format: date-time
                    description: Newest post seen from this source; `null` when none recorded yet.
                  rows_last_24h:
                    type: integer
                  dead_after_seconds:
                    type: [integer, "null"]
                  age_seconds:
                    type: [integer, "null"]
                    description: Seconds since `newest_item_at`; `null` when unknown.
                  dead:
                    type: [boolean, "null"]
                    description: Verdict for accounts only; `null` for hashtags or when the age is unknown.
                    nullable: true
                    description: Verdict for `account` sources only; always `null` for `hashtag`.

    StatusArtifact:
      type: object
      required: [kind, path, updated_at, age_seconds, stale]
      description: |
        Freshness facts for one artifact. Shape varies by `kind` - fields
        beyond the common ones below are additive and kind-dependent
        (e.g. `last_row`/`data_stale` only on `kind: tick`,
        `stale_after_seconds: null` plus a `note` on `kind:
        change-driven`), so this schema stays permissive.
      properties:
        kind:
          type: string
          enum: [tick, own-clock, change-driven]
          description: |
            `tick`: shares the main pipeline's own clock (today/week/
            month/year.csv, rss.xml, counts.json, misp, stix,
            blocklists, exclusive.csv, contributors.json). `own-clock`: refreshed on its own schedule,
            slower than the main pipeline (lookup, trends.json,
            external.json, ipmeta.json, enrichment.json,
            campaigns.json). `change-driven`: `generated_at` only moves
            when the content itself changes, so `stale_after_seconds` is
            `null` (archive, domainmeta.json).
        path:
          type: string
          example: today.csv
        updated_at:
          type: string
          format: date-time
          nullable: true
        interval_seconds:
          type: integer
          description: Expected refresh cadence for this artifact, in seconds.
        stale_after_seconds:
          type: integer
          nullable: true
          description: |
            Threshold past `age_seconds` that flips this artifact's
            `stale` to `true`. `null` on `kind: change-driven` artifacts,
            which have no fixed staleness window.
        age_seconds:
          type: integer
          nullable: true
          description: Seconds since this artifact's `updated_at`.
        stale:
          type: boolean
          description: This one artifact's own freshness verdict.
        rows:
          type: integer
          nullable: true
          description: Row count, only present on CSV-shaped artifacts.
        first_row:
          type: string
          nullable: true
          description: Timestamp of the oldest row, only on CSV-shaped tick artifacts.
        last_row:
          type: string
          nullable: true
          description: |
            Timestamp of the newest row, only on CSV-shaped tick
            artifacts - the basis for `data_age_seconds`/`data_stale`.
        data_stale_after_seconds:
          type: integer
          description: Threshold for `data_age_seconds`, tick artifacts only.
        data_age_seconds:
          type: integer
          description: Seconds since `last_row` (the data itself, not the file). Tick artifacts only.
        data_stale:
          type: boolean
          description: |
            `true` if the newest row is older than
            `data_stale_after_seconds` even though the file itself was
            just rewritten (e.g. a quiet tag/window). Tick artifacts only.
        note:
          type: string
          description: Human-readable caveat, present on some `own-clock`/`change-driven` artifacts.
        shards:
          type: integer
          description: Present only on the `lookup` artifact.
        upstream_stale:
          type: boolean
          description: Present only on `campaigns.json`.
        upstream_stale_since:
          type: string
          nullable: true
          description: Present only on `campaigns.json`.
      additionalProperties: true

    ManifestDocument:
      type: object
      required: [schema_version, generated_at, base_url, api_base, artifacts, families]
      properties:
        schema_version:
          type: integer
          example: 1
        generated_at:
          type: string
          format: date-time
          example: "2026-09-05T14:45:56Z"
        base_url:
          type: string
          description: Raw GitHub base every artifact `url` below is relative to.
          example: "https://raw.githubusercontent.com/0xDanielLopez/TweetFeed/master/"
        api_base:
          type: string
          example: "https://api.tweetfeed.live"
        note:
          type: string
        sources:
          type: object
          description: Source-coverage summary, mirroring the counts in `/v1/status`'s `sources`.
          properties:
            total:
              type: integer
              example: 47
            hashtag:
              type: integer
              example: 38
            account:
              type: integer
              example: 9
        artifacts:
          type: array
          items:
            $ref: "#/components/schemas/ManifestArtifact"
        families:
          type: array
          description: |
            Sharded/multi-file surfaces that don't reduce to one row
            (`lookup`, `misp`, `stix`, `archive`, `campaigns`,
            per-tag/type/user RSS).
          items:
            type: object
            required: [name, path]
            properties:
              name:
                type: string
              path:
                type: string
              shards:
                type: integer
              meta:
                type: string
              manifest:
                type: string
            additionalProperties: true

    ManifestArtifact:
      type: object
      required: [name, path, format, bytes, sha256, updated_at, url]
      description: |
        `sha256`/`bytes`/`rows` describe the file in the same data-repo
        commit as the manifest document itself - see the `/v1/manifest`
        path description for the tick-boundary caveat.
      properties:
        name:
          type: string
          example: today.csv
        path:
          type: string
          description: Path inside the data repo (differs from `name` for blocklist files).
          example: today.csv
        format:
          type: string
          enum: [csv, json, rss, txt]
        window:
          type: [string, "null"]
          enum: [today, week, month, year, null]
        bytes:
          type: integer
          nullable: true
        rows:
          type: integer
          nullable: true
          description: Row count. Null for non-row-shaped formats (rss, most json).
        sha256:
          type: string
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true
        url:
          type: string
          description: Raw GitHub URL for this exact file.
        api_url:
          type: string
          nullable: true
          description: |
            API-fronted URL, `null` when this artifact has no `/v1/*`
            route of its own.
      additionalProperties: true
