{
  "openapi": "3.1.0",
  "info": {
    "title": "Slop Detector API",
    "version": "0.8.0",
    "summary": "Deterministic AI-design-slop scoring for landing pages, plus an AEO axis.",
    "description": "Score any landing page against the AI-design fingerprint. The API runs the same deterministic, weighted 0-100 engine as the web app and CLI, on real headless Chromium (Cloudflare Browser Rendering). No API key is required for normal use; requests are rate-limited per IP and the browser-driving routes (/api/scan, /api/aeo) are protected by a Turnstile challenge for browser origins. Foreign browser origins must present an API key. CLI / server-to-server callers (no Origin header) are allowed but rate-limited harder.",
    "license": {
      "name": "MIT",
      "url": "https://opensource.org/licenses/MIT"
    },
    "contact": {
      "name": "Ravindra Kumar",
      "url": "https://github.com/ravidsrk/slop-detect/issues",
      "email": "ravidsrk@gmail.com"
    }
  },
  "servers": [
    {
      "url": "https://slop-detect.com",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "Source, CLI, and MCP server",
    "url": "https://github.com/ravidsrk/slop-detect"
  },
  "tags": [
    {
      "name": "scan",
      "description": "Run the slop engine against a URL."
    },
    {
      "name": "aeo",
      "description": "Score whether AI engines can read & cite a page."
    },
    {
      "name": "catalogue",
      "description": "Read the live pattern catalogue."
    },
    {
      "name": "fix",
      "description": "Generate an LLM fix-prompt from a scan."
    },
    {
      "name": "monitor",
      "description": "Monitor a domain: daily re-scans, regression + design-drift email alerts (double opt-in)."
    },
    {
      "name": "directory",
      "description": "Opt-in public catalogue of scored sites."
    },
    {
      "name": "dashboard",
      "description": "Magic-link sign-in to the multi-domain dashboard."
    }
  ],
  "paths": {
    "/api/scan": {
      "post": {
        "operationId": "scanPage",
        "tags": [
          "scan"
        ],
        "summary": "Score a URL against the AI-design slop fingerprint.",
        "description": "Opens the URL in headless Chromium, inspects up to 4,000 visible DOM nodes against the deterministic pattern catalogue, and returns a weighted 0-100 score plus the patterns that fired. Opt into the copy axis with `axes: ['design','copy']`.",
        "security": [
          {},
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScanRequest"
              },
              "examples": {
                "default": {
                  "summary": "Design axis only",
                  "value": {
                    "url": "https://example.com"
                  }
                },
                "multiAxis": {
                  "summary": "Design + copy axes",
                  "value": {
                    "url": "https://example.com",
                    "axes": [
                      "design",
                      "copy"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Scan result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/UpstreamError"
          }
        }
      }
    },
    "/api/aeo": {
      "post": {
        "operationId": "checkAeo",
        "tags": [
          "aeo"
        ],
        "summary": "Score whether AI engines can fetch, read & cite a page.",
        "description": "Runs the AEO axis: a handful of plain edge fetches (HTML, GPTBot UA, robots.txt, markdown twin, llms.txt) scored against the AEO check catalogue. Higher is better — inverse polarity of the slop score. No browser needed.",
        "security": [
          {},
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AeoRequest"
              },
              "examples": {
                "default": {
                  "value": {
                    "url": "https://example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "AEO conformance report.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AeoResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/patterns": {
      "get": {
        "operationId": "listPatterns",
        "tags": [
          "catalogue"
        ],
        "summary": "The live pattern catalogue.",
        "description": "Returns every design-slop pattern the engine checks, with id, label, category, weight, and the version it was added in. The count is derived from the engine — never hardcode it.",
        "responses": {
          "200": {
            "description": "Pattern catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PatternCatalogue"
                }
              }
            }
          }
        }
      }
    },
    "/api/fix-prompt": {
      "post": {
        "operationId": "buildFixPrompt",
        "tags": [
          "fix"
        ],
        "summary": "Build an LLM fix-prompt from a scan.",
        "description": "Two modes: pass `{ result }` to assemble a prompt from a scan you already have (cheap), or `{ url }` to re-run a scan first (gated as a scan). Returns a ready-to-paste prompt that tells a coding agent how to de-slop the page.",
        "security": [
          {},
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FixPromptRequest"
              },
              "examples": {
                "fromResult": {
                  "summary": "Assemble from an existing result",
                  "value": {
                    "result": {
                      "score": 34,
                      "tier": "Heavy",
                      "patterns": []
                    }
                  }
                },
                "fromUrl": {
                  "summary": "Re-scan then assemble",
                  "value": {
                    "url": "https://example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Fix prompt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FixPromptResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/watch": {
      "post": {
        "operationId": "watchDomain",
        "tags": [
          "monitor"
        ],
        "summary": "Start (or stop) monitoring a domain.",
        "description": "Subscribe a domain to daily re-scans with regression alerts (double opt-in: no email is sent until the address confirms). `system: true` also checks the domain against its <origin>/DESIGN.md and alerts on design drift. `list: true` opts into the public directory (dofollow backlink). Flags are omit-preserves: only send a flag to change it. `unsubscribe: true` (with the matching email) stops monitoring and delists. Once claimed, only the same email may modify a domain (403 otherwise).",
        "security": [
          {},
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain",
                  "email"
                ],
                "properties": {
                  "domain": {
                    "type": "string",
                    "example": "example.com"
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "system": {
                    "type": "boolean",
                    "description": "Enable DESIGN.md drift monitoring."
                  },
                  "list": {
                    "type": "boolean",
                    "description": "List in the public directory."
                  },
                  "unsubscribe": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated (or unsubscribed)."
          },
          "201": {
            "description": "Monitoring registered."
          },
          "400": {
            "description": "Error.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Domain claimed by a different email."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "operationId": "watchStatus",
        "tags": [
          "monitor"
        ],
        "summary": "Public monitoring status + score history for a domain.",
        "parameters": [
          {
            "name": "domain",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Status (never includes the subscriber email)."
          },
          "400": {
            "description": "Error.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/watch/confirm": {
      "get": {
        "operationId": "confirmWatch",
        "tags": [
          "monitor"
        ],
        "summary": "Double-opt-in confirmation (link from the verification email).",
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Confirmed (HTML)."
          },
          "410": {
            "description": "Link expired or already used (HTML)."
          }
        }
      }
    },
    "/api/sites": {
      "get": {
        "operationId": "listSites",
        "tags": [
          "directory"
        ],
        "summary": "The opt-in public directory of scored sites (JSON).",
        "parameters": [
          {
            "name": "sort",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "clean",
                "slop"
              ],
              "default": "clean"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Globally sorted listings; matches /directory."
          }
        }
      }
    },
    "/api/dashboard/link": {
      "post": {
        "operationId": "dashboardLink",
        "tags": [
          "dashboard"
        ],
        "summary": "Request a magic sign-in link for the multi-domain dashboard.",
        "description": "The response is identical whether or not the email monitors anything (anti-enumeration); a single-use, 15-minute link is only actually emailed when it does. 503 until the email provider and SESSION_SECRET are configured.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Generic acknowledgement."
          },
          "400": {
            "description": "Error.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Dashboard sign-in not configured."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Optional. Required only for browser requests from origins outside the allow-list. Server-to-server and CLI callers (no Origin header) do not need a key but are rate-limited."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid JSON body or invalid/blocked URL (SSRF guard).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Foreign browser origin without a valid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Per-IP rate limit exceeded. The scan route fails closed under load.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "UpstreamError": {
        "description": "The target page could not be loaded/scored (timeout, anti-bot wall, dead page).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "ScanRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Public http(s) URL to scan. Private/loopback/metadata hosts are rejected.",
            "example": "https://example.com"
          },
          "preset": {
            "type": "string",
            "enum": [
              "full",
              "strict",
              "marketing",
              "minimal"
            ],
            "default": "full",
            "description": "Scoring preset. `full` scores everything; `strict` only weight>=5 smoking guns; `marketing` brand-surface tells; `minimal` the three dead-giveaways."
          },
          "axes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "design",
                "copy"
              ]
            },
            "description": "Axes to score. Omit for design only; use ['design','copy'] or 'all' for both."
          },
          "screenshot": {
            "type": "boolean",
            "default": false,
            "description": "Include a base64 screenshot in the response."
          },
          "share": {
            "type": "boolean",
            "default": true,
            "description": "Persist a shareable permalink (/r/:id). Set false for CI dry-runs."
          },
          "designMd": {
            "oneOf": [
              {
                "type": "boolean"
              },
              {
                "type": "string",
                "format": "uri"
              }
            ],
            "description": "System axis - check the page against its declared design system (DESIGN.md spec). `true` looks for <origin>/DESIGN.md; a URL fetches that file (SSRF-guarded). Adds a `system` block to the response: 0-100 compliance score (HIGHER is better), tier Aligned/Drifting/Off-system, and named drift items."
          }
        }
      },
      "ScanResult": {
        "type": "object",
        "description": "Top-level fields are the DESIGN axis (backward-compatible). When the copy axis is requested, an `axes` object and combined summary are added.",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "finalUrl": {
            "type": "string",
            "format": "uri",
            "description": "URL after redirects."
          },
          "title": {
            "type": "string"
          },
          "h1": {
            "type": "string"
          },
          "h1Font": {
            "type": "string"
          },
          "preset": {
            "type": "string"
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Weighted design-slop score. Higher = more machine-made."
          },
          "tier": {
            "type": "string",
            "enum": [
              "Clean",
              "Mild",
              "Heavy"
            ],
            "description": "Clean 0-9, Mild 10-27, Heavy 28+."
          },
          "grade": {
            "type": "string",
            "description": "Letter grade A-F."
          },
          "patternsFlagged": {
            "type": "integer"
          },
          "patternsTotal": {
            "type": "integer"
          },
          "patterns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Pattern"
            }
          },
          "navMs": {
            "type": "integer",
            "description": "Page navigation time in ms."
          },
          "id": {
            "type": "string",
            "description": "Present when shared. Permalink id."
          },
          "resultUrl": {
            "type": "string",
            "format": "uri",
            "description": "Shareable permalink, present when shared."
          },
          "axes": {
            "type": "object",
            "description": "Present only when the copy axis is requested.",
            "properties": {
              "design": {
                "$ref": "#/components/schemas/AxisSummary"
              },
              "copy": {
                "$ref": "#/components/schemas/AxisSummary"
              }
            }
          },
          "system": {
            "type": "object",
            "description": "Present when designMd was set. Alignment with the site's OWN design system - higher is better; NOT folded into the slop score. Keys: declared, score, tier, checks, drift[], source."
          }
        }
      },
      "AxisSummary": {
        "type": "object",
        "properties": {
          "axis": {
            "type": "string",
            "enum": [
              "design",
              "copy"
            ]
          },
          "score": {
            "type": "integer"
          },
          "tier": {
            "type": "string"
          },
          "grade": {
            "type": "string"
          },
          "patternsFlagged": {
            "type": "integer"
          },
          "patternsTotal": {
            "type": "integer"
          },
          "patterns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Pattern"
            }
          }
        }
      },
      "Pattern": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "gradient_hero_text"
          },
          "label": {
            "type": "string"
          },
          "short": {
            "type": "string"
          },
          "category": {
            "type": "string",
            "nullable": true,
            "enum": [
              "type",
              "color",
              "layout",
              "copy",
              null
            ]
          },
          "weight": {
            "type": "number"
          },
          "since": {
            "type": "string",
            "description": "Version a pattern was added.",
            "example": "baseline"
          },
          "hit": {
            "type": "boolean",
            "description": "Whether this pattern fired on the scanned page."
          }
        }
      },
      "PatternCatalogue": {
        "type": "object",
        "properties": {
          "version": {
            "type": "string",
            "example": "2026.09",
            "description": "DEFINITIONS_VERSION of the catalogue."
          },
          "count": {
            "type": "integer",
            "description": "Number of patterns. Derived from the engine.",
            "example": 27
          },
          "patterns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Pattern"
            }
          }
        }
      },
      "AeoRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "example": "https://example.com"
          }
        }
      },
      "AeoResult": {
        "type": "object",
        "description": "AEO conformance report. Score polarity is INVERTED vs the slop score — higher is better.",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "0-100. Higher = more AI-readable."
          },
          "tier": {
            "type": "string",
            "enum": [
              "AI-Ready",
              "Partial",
              "Invisible"
            ],
            "description": "AI-Ready >=80, Partial >=50, Invisible <50."
          },
          "checks": {
            "type": "array",
            "description": "Per-check results (required + recommended).",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "example": "bot.notBlocked"
                },
                "label": {
                  "type": "string"
                },
                "tier": {
                  "type": "string",
                  "enum": [
                    "required",
                    "recommended"
                  ]
                },
                "pass": {
                  "type": "boolean"
                },
                "weight": {
                  "type": "number"
                }
              }
            }
          }
        }
      },
      "FixPromptRequest": {
        "type": "object",
        "description": "Provide exactly one of `result` or `url`.",
        "properties": {
          "result": {
            "$ref": "#/components/schemas/ScanResult"
          },
          "url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "FixPromptResult": {
        "type": "object",
        "properties": {
          "prompt": {
            "type": "string",
            "description": "Ready-to-paste prompt instructing a coding agent how to de-slop the page."
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable error message."
          }
        }
      }
    }
  }
}
