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-01-01. 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: 60 s for `/v1/{time}`, `/v1/ioc`, `/v1/campaigns`,
      `/v1/trends` and `/v1/counts`; 30 s for `/v1/since`; 15 min for `/v1/blocklist`
      (all with `stale-while-revalidate`).
    - 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, 10 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.
      parameters:
        - $ref: "#/components/parameters/Time"
        - $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"

  /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: 92 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/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"

  /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/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"

  /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 - `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/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.
        "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/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"

  /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/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"

  /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 available as a path parameter: `/v1/ioc/{value}`.
      parameters:
        - $ref: "#/components/parameters/IocValueQuery"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: |
            Lookup result. `found: false` with an empty `records` array when
            there is no match.
          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"

  /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.
      parameters:
        - $ref: "#/components/parameters/IocValuePath"
        - $ref: "#/components/parameters/IfNoneMatch"
        - $ref: "#/components/parameters/IfModifiedSince"
      responses:
        "200":
          description: |
            Lookup result. `found: false` with an empty `records` array when
            there is no match.
          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"

  /v1/campaigns:
    get:
      operationId: getCampaigns
      summary: List AI-clustered threat campaigns
      description: |
        Returns AI-clustered campaign groupings of the last 7 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/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/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.
      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"

components:
  parameters:
    Time:
      name: time
      in: path
      required: true
      description: Time window for the query.
      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 URL-level export: full URLs, not just
        hosts, for `url`-type IOCs on shared/legitimate infrastructure that
        host/DNS blocking can't safely cover.
      schema:
        type: string
        enum: [domains.txt, hosts.txt, adguard.txt, ips.txt, rpz.txt, dnsmasq.txt, urls.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

    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"

    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"

  schemas:
    Ioc:
      type: object
      required: [date, user, type, value, tags]
      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_url` 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.
          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: 92 tags. Full list at
            https://tweetfeed.live/tags/.
          example: ["phishing", "scam"]

    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).
        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.
          description: |
            Optional. Other IOCs posted in the same source tweets as this one
            (sample, max 5). Absent when there are none.

    IocLookupResult:
      type: object
      required: [found, query, window, records]
      properties:
        found:
          type: boolean
          description: Whether at least one matching record was found.
        query:
          type: string
          description: The normalised (refanged) value that was looked up.
        window:
          type: string
          description: Retention window searched.
          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).
            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: 3
          description: |
            Optional. Cross-feed corroboration from public abuse.ch feeds
            (URLhaus/ThreatFox); absent when no match or the sidecar is
            unavailable.
          items:
            type: object
            required: [src, threat, link, added]
            properties:
              src:
                type: string
                enum: [urlhaus, threatfox]
                description: Which abuse.ch 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.
              added:
                type: string
                format: date
                description: Date the indicator was added to the source feed.
        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.

    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.

    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: [week]
          description: Source window for clustering - always the rolling last 7 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
          description: Date of the last successful run. 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"

    Campaign:
      type: object
      required:
        [id, name, context, confidence, targeted_brand, 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.
        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
        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}
        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
