{
  "openapi": "3.1.0",
  "info": {
    "title": "Topfeed API",
    "version": "1.0.0",
    "summary": "Public read API for the Topfeed RSS and Atom feed directory.",
    "description": "Topfeed is a free RSS and Atom feed reader and curated directory. This API exposes the public feed directory: search and list curated feeds, and read a feed's latest entries. Read endpoints require no authentication or API key and return JSON. Errors are JSON objects with an `error` message. Human-readable guide: https://topfeed.xyz/docs. Agent guide: https://topfeed.xyz/llms.txt.",
    "contact": {
      "name": "Topfeed",
      "url": "https://topfeed.xyz/about"
    }
  },
  "externalDocs": {
    "description": "Topfeed API documentation",
    "url": "https://topfeed.xyz/docs"
  },
  "servers": [
    {
      "url": "https://topfeed.xyz",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "feeds",
      "description": "Curated RSS and Atom feeds in the public Topfeed directory."
    }
  ],
  "paths": {
    "/api/feeds": {
      "get": {
        "tags": ["feeds"],
        "operationId": "listFeeds",
        "summary": "List and search curated feeds",
        "description": "Returns a paginated list of approved feeds from the public directory. No authentication required. Use `q` for a case-insensitive substring search over feed title, description, feed URL, and website URL. Combine `sort`, `stale`, and pagination to build feed discovery tools.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match against feed title, description, feed URL, and website URL.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Sort order. `users`: most readers first (ties broken by post count). `latest`: most recent post first. `title`: alphabetical, case-insensitive. `activity`: most new entries first, then staleness and recency.",
            "schema": {
              "type": "string",
              "enum": ["users", "latest", "title", "activity"],
              "default": "users"
            }
          },
          {
            "name": "stale",
            "in": "query",
            "required": false,
            "description": "Filter by fetch health. `any`: all feeds. `exclude`: only feeds that are still fetched successfully. `only`: only feeds whose recent fetches failed.",
            "schema": {
              "type": "string",
              "enum": ["any", "exclude", "only"],
              "default": "any"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Moderation status filter. Only `approved` is public; any other value requires an admin session and returns 403 for anonymous callers.",
            "schema": {
              "type": "string",
              "enum": ["approved", "pending", "rejected", "all"],
              "default": "approved"
            }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-based page number. Values below 1 are clamped to 1.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "required": false,
            "description": "Feeds per page. Values are clamped to the range 1-250.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 250,
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of feeds matching the filters.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FeedListResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid `status` value.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "Invalid status"
                }
              }
            }
          },
          "403": {
            "description": "A non-public `status` was requested without an admin session.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "Forbidden"
                }
              }
            }
          }
        }
      }
    },
    "/api/feeds/{slug}": {
      "get": {
        "tags": ["feeds"],
        "operationId": "getFeedBySlug",
        "summary": "Get a feed and its latest entries",
        "description": "Returns one feed by its directory slug together with a paginated page of its entries, newest first. No authentication required. Slugs are the identifiers used in directory URLs such as https://topfeed.xyz/feeds/simon-willison and in the `slug` field of `listFeeds` results.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "URL-safe feed slug from the directory, for example `simon-willison`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-based page number of entries. Values below 1 are clamped to 1.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "required": false,
            "description": "Entries per page. Values are clamped to the range 1-100.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The feed and a page of its entries, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FeedDetailResponse"
                }
              }
            }
          },
          "400": {
            "description": "Missing slug.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "Missing slug"
                }
              }
            }
          },
          "404": {
            "description": "No feed exists for this slug.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "Feed not found"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Feed": {
        "type": "object",
        "description": "A curated RSS or Atom feed in the Topfeed directory.",
        "properties": {
          "_id": {
            "type": "string",
            "description": "Opaque feed identifier."
          },
          "description": {
            "type": ["string", "null"],
            "description": "Feed description from the publisher, when available."
          },
          "imageUrl": {
            "type": ["string", "null"],
            "description": "Feed logo or image URL, when available."
          },
          "isStale": {
            "type": "boolean",
            "description": "True when recent fetch attempts for this feed failed."
          },
          "lastFetched": {
            "type": ["integer", "null"],
            "description": "Last successful fetch time as Unix milliseconds."
          },
          "latestPubDate": {
            "type": ["integer", "null"],
            "description": "Publication time of the newest entry as Unix milliseconds."
          },
          "newEntriesCount": {
            "type": "integer",
            "description": "Number of entries found in the most recent fetch."
          },
          "slug": {
            "type": "string",
            "description": "URL-safe identifier used in /feeds/{slug} pages and in this API."
          },
          "status": {
            "type": "string",
            "enum": ["pending", "approved", "rejected"],
            "description": "Moderation status. Public listings only contain `approved` feeds."
          },
          "submittedBy": {
            "type": ["string", "null"],
            "description": "Identifier of the user who submitted the feed, when community-submitted."
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Topic tags such as tech, design, ai, or startup."
          },
          "title": {
            "type": "string",
            "description": "Feed title."
          },
          "totalEntriesCount": {
            "type": "integer",
            "description": "Total entries currently stored for this feed."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The RSS or Atom feed URL."
          },
          "userCount": {
            "type": "integer",
            "description": "Number of Topfeed readers subscribed to this feed."
          },
          "websiteUrl": {
            "type": ["string", "null"],
            "description": "The publisher's website URL, when known."
          }
        },
        "required": [
          "_id",
          "description",
          "imageUrl",
          "isStale",
          "lastFetched",
          "latestPubDate",
          "newEntriesCount",
          "slug",
          "status",
          "submittedBy",
          "tags",
          "title",
          "totalEntriesCount",
          "url",
          "userCount",
          "websiteUrl"
        ]
      },
      "FeedEntry": {
        "type": "object",
        "description": "One article or post published by a feed. Entries link to the original publisher's website.",
        "properties": {
          "_id": {
            "type": "string",
            "description": "Opaque entry identifier."
          },
          "title": {
            "type": "string",
            "description": "Entry title."
          },
          "url": {
            "type": "string",
            "description": "URL of the article on the publisher's website."
          },
          "author": {
            "type": ["string", "null"],
            "description": "Entry author, when the feed provides one."
          },
          "publishedAt": {
            "type": ["integer", "null"],
            "description": "Publication time as Unix milliseconds."
          },
          "contentSnippet": {
            "type": ["string", "null"],
            "description": "First 240 characters of the entry content, when available."
          }
        },
        "required": [
          "_id",
          "title",
          "url",
          "author",
          "publishedAt",
          "contentSnippet"
        ]
      },
      "FeedListResponse": {
        "type": "object",
        "description": "A paginated page of feeds.",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Feed"
            }
          },
          "page": {
            "type": "integer",
            "description": "The returned page number."
          },
          "pageSize": {
            "type": "integer",
            "description": "The effective page size after clamping."
          },
          "total": {
            "type": "integer",
            "description": "Total number of feeds matching the filters."
          }
        },
        "required": ["items", "page", "pageSize", "total"]
      },
      "FeedEntriesPage": {
        "type": "object",
        "description": "A paginated page of feed entries, newest first.",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FeedEntry"
            }
          },
          "page": {
            "type": "integer",
            "description": "The returned page number."
          },
          "pageSize": {
            "type": "integer",
            "description": "The effective page size after clamping."
          },
          "total": {
            "type": "integer",
            "description": "Total number of entries stored for the feed."
          }
        },
        "required": ["items", "page", "pageSize", "total"]
      },
      "FeedDetailResponse": {
        "type": "object",
        "description": "A feed together with a page of its entries.",
        "properties": {
          "feed": {
            "$ref": "#/components/schemas/Feed"
          },
          "entries": {
            "$ref": "#/components/schemas/FeedEntriesPage"
          }
        },
        "required": ["feed", "entries"]
      },
      "Error": {
        "type": "object",
        "description": "JSON error body returned by API error responses, with an HTTP status such as 400, 403, or 404.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable error message explaining what went wrong."
          }
        },
        "required": ["error"]
      }
    }
  }
}
