{
  "openapi": "3.1.0",
  "info": {
    "title": "LeanForge Keyword Trend API",
    "version": "1.0.0",
    "description": "Public keyword trend, category, comparison, export, blog, and health endpoints."
  },
  "servers": [
    {
      "url": "https://lean-forge.net"
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "summary": "Liveness probe",
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/health/deep": {
      "get": {
        "summary": "Database, Redis, and data-freshness probe",
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/api/v1/keywords": {
      "get": {
        "summary": "List active keywords",
        "parameters": [
          {
            "$ref": "#/components/parameters/Category"
          },
          {
            "$ref": "#/components/parameters/Direction"
          },
          {
            "$ref": "#/components/parameters/Search"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Offset"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/KeywordList"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/keywords/trending": {
      "get": {
        "summary": "List rising keywords, falling back to top trend scores",
        "parameters": [
          {
            "$ref": "#/components/parameters/Category"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/KeywordList"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/keywords/export": {
      "get": {
        "summary": "Download up to 25 active keywords as CSV or JSON",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "csv",
                "json"
              ],
              "default": "csv"
            }
          },
          {
            "$ref": "#/components/parameters/Category"
          },
          {
            "$ref": "#/components/parameters/Direction"
          },
          {
            "$ref": "#/components/parameters/Search"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 25,
              "default": 25
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Download attachment",
            "headers": {
              "Content-Disposition": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "description": "CRLF-terminated CSV. The header row is exactly the KeywordExportRow properties in order: term,slug,category,trendScore,velocity,direction,source,dataAsOf,updatedAt. Null category and null dataAsOf are empty cells; updatedAt and dataAsOf are ISO 8601 date-times. Cells starting with = + - @ are prefixed with an apostrophe so spreadsheets do not evaluate them as formulas."
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KeywordExportResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/keywords/{slug}": {
      "get": {
        "summary": "Get a keyword and its history",
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/keywords/{slug}/next-actions": {
      "get": {
        "summary": "Get the deterministic publish/track/hold/skip verdict",
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/categories": {
      "get": {
        "summary": "List categories",
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          }
        }
      }
    },
    "/api/v1/categories/{slug}": {
      "get": {
        "summary": "Get a category and its keywords",
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/trends": {
      "get": {
        "summary": "List trend data points",
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/trends/daily": {
      "get": {
        "summary": "Get Google daily trends",
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          }
        }
      }
    },
    "/api/v1/trends/realtime": {
      "get": {
        "summary": "Get real-time trending topics",
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          }
        }
      }
    },
    "/api/v1/trends/compare": {
      "get": {
        "summary": "Compare 2 to 5 keywords",
        "parameters": [
          {
            "name": "keywords",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "chatgpt,claude-ai"
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/trends/{keywordId}/timeline": {
      "get": {
        "summary": "Get a keyword timeline",
        "parameters": [
          {
            "name": "keywordId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Offset"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/blog": {
      "get": {
        "summary": "List published blog posts",
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          }
        }
      }
    },
    "/api/v1/blog/{slug}": {
      "get": {
        "summary": "Get a published blog post",
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/JsonOk"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "Category": {
        "name": "category",
        "in": "query",
        "schema": {
          "type": "string"
        }
      },
      "Direction": {
        "name": "direction",
        "in": "query",
        "schema": {
          "type": "string",
          "enum": [
            "rising",
            "falling",
            "flat"
          ]
        }
      },
      "Search": {
        "name": "q",
        "in": "query",
        "schema": {
          "type": "string",
          "maxLength": 120
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100
        }
      },
      "Offset": {
        "name": "offset",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        }
      },
      "Slug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        }
      }
    },
    "schemas": {
      "Keyword": {
        "type": "object",
        "description": "A tracked keyword. Trend fields come from Google Trends. No search-volume, CPC, or difficulty fields are returned: none was ever measured (the former searchVolume and cpc were always 0; difficulty was a page-one result count that is no longer refreshed) and they were removed rather than served as data.",
        "required": [
          "id",
          "term",
          "slug",
          "trendScore",
          "velocity",
          "direction",
          "source",
          "isActive",
          "createdAt",
          "updatedAt"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "term": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "category": {
            "type": [
              "string",
              "null"
            ]
          },
          "trendScore": {
            "type": "number",
            "minimum": 0,
            "maximum": 100
          },
          "velocity": {
            "type": "number"
          },
          "direction": {
            "type": "string",
            "enum": [
              "rising",
              "falling",
              "flat"
            ]
          },
          "source": {
            "type": "string"
          },
          "isActive": {
            "type": "boolean"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Row write timestamp. NOT a data-freshness signal — the nightly snapshot job restamps every row. Use dataAsOf."
          },
          "categoryRel": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "breakdown": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Trend Score breakdown from the latest daily snapshot; null when no snapshot exists."
          },
          "dataAsOf": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Date of the newest measurement this keyword's trend score derives from. Use THIS as the freshness signal, not updatedAt: updatedAt is a row write timestamp that the nightly snapshot job restamps for every keyword whether or not new data arrived. Null when the keyword has no snapshot yet."
          }
        }
      },
      "KeywordListResponse": {
        "type": "object",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Keyword"
            }
          },
          "_meta": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "KeywordExportRow": {
        "type": "object",
        "description": "One exported keyword. This is NOT the Keyword schema: the export carries no id, isActive, createdAt, categoryRel or breakdown, and adds nothing else. Property order is the CSV column order.",
        "required": [
          "term",
          "slug",
          "category",
          "trendScore",
          "velocity",
          "direction",
          "source",
          "dataAsOf",
          "updatedAt"
        ],
        "properties": {
          "term": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "category": {
            "type": [
              "string",
              "null"
            ]
          },
          "trendScore": {
            "type": "number",
            "minimum": 0,
            "maximum": 100
          },
          "velocity": {
            "type": "number"
          },
          "direction": {
            "type": "string",
            "enum": [
              "rising",
              "falling",
              "flat"
            ]
          },
          "source": {
            "type": "string"
          },
          "dataAsOf": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Date of the newest measurement this row's score derives from. Placed before updatedAt deliberately: the first date a reader meets is the data date. Null when the keyword has no snapshot yet — never a fabricated date."
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Row write timestamp. NOT a data-freshness signal — the nightly snapshot job restamps every row. Use dataAsOf."
          }
        }
      },
      "KeywordExportResponse": {
        "type": "object",
        "description": "JSON export body (format=json). Unlike KeywordListResponse there is no _meta object.",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/KeywordExportRow"
            }
          }
        },
        "additionalProperties": false
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {}
        },
        "required": [
          "error"
        ]
      }
    },
    "responses": {
      "KeywordList": {
        "description": "Keyword list",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/KeywordListResponse"
            }
          }
        }
      },
      "JsonOk": {
        "description": "Successful JSON response",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "additionalProperties": true
            }
          }
        }
      },
      "BadRequest": {
        "description": "Invalid request",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "Dependency unavailable",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}