{
  "openapi": "3.0.3",
  "info": {
    "title": "aeogeo.site Customer REST API",
    "version": "1.0.0",
    "description": "Deterministic AEO/GEO checks for being cited by AI — no inference on our bill. POST /api/scan returns an aggregate grade without a key and per-check detail with `Authorization: Bearer <key>`; keyed GET /api/checks and GET /api/rules feed CLI/GitHub-Action linting. MCP at https://mcp.aeogeo.site remains the primary agent surface. Errors use the FR-ERR envelope (ok:false with code, message, executable fix, and docs link)."
  },
  "servers": [
    {
      "url": "https://aeogeo.site",
      "description": "Production API and static apex."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/api/scan": {
      "post": {
        "operationId": "scanUrl",
        "summary": "Scan a public URL against the active ruleset",
        "description": "Call when an agent needs to check whether a live public URL is configured to be cited by AI engines. Deterministic: same URL + same ruleset -> same output. Keyless calls return an aggregate grade (FreeScanOutput) with no rule ids, per-check arrays, or fix hints. Pass `Authorization: Bearer <key>` (Pro/Team) to receive per-check detail (KeyedScanOutput). Respects per-domain/per-IP/plan daily quotas and serves cached results for up to 1h. Scanned content is never persisted.",
        "security": [
          {},
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "Authorization",
            "in": "header",
            "required": false,
            "description": "Optional Bearer API key. Omit for the free (aggregate) output; pass a Pro/Team key for detailed per-check results (FR-SCAN-4).",
            "schema": {
              "type": "string",
              "pattern": "^Bearer "
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "The public URL to scan.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScanRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Scan result. Keyless callers receive the aggregate FreeScanOutput; keyed callers receive KeyedScanOutput with a checks[] array.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/FreeScanOutput"
                    },
                    {
                      "$ref": "#/components/schemas/KeyedScanOutput"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "invalid_json / invalid_url / blocked_url — body is not valid JSON, the url is not a public http(s) URL, or the target is on a blocked private range.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "description": "invalid_api_key — the supplied Authorization header does not match an active key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "402": {
            "description": "subscription_past_due — the key's subscription is past due (fix points at the payment link).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "413": {
            "description": "payload_too_large — the target page exceeds the 2MB scanner budget.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "description": "rate_limited — daily quota exhausted (keyless: 5/domain + 20/IP per day; keyed: plan quota). The fix tells the agent how long to wait or to pass a key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "internal_error — unexpected scanner failure; retry or contact support.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "502": {
            "description": "fetch_failed — the target page could not be fetched within the 10s / 2MB budget.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/checks": {
      "get": {
        "operationId": "listChecks",
        "summary": "Fetch applicable check descriptors",
        "description": "Call before linting content locally (CLI `aeogeo lint`, GitHub Action) to fetch the active check descriptors. The output is a ruleset version plus an array of checks; filter with `content_type` (CSV) to retrieve only the checks relevant to blog, landing, faq, or product pages. Requires a Pro or Team API key in `Authorization: Bearer <key>`.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "content_type",
            "in": "query",
            "required": false,
            "description": "Optional comma-separated content types (e.g. blog,landing). Checks whose applies_to intersects any listed type (or that apply universally) are returned; omit for all checks.",
            "schema": {
              "type": "string",
              "example": "blog,landing"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ruleset version plus the (optionally filtered) check descriptors for the CLI to run locally.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChecksResponse"
                }
              }
            }
          },
          "401": {
            "description": "invalid_api_key — missing/unknown Authorization header.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "402": {
            "description": "subscription_past_due — the key's subscription is past due.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "403": {
            "description": "requires_pro — a free/keyless caller must pass a Pro or Team key to fetch descriptors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "description": "rate_limited — keyed daily quota exhausted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "internal_error — ruleset could not be loaded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/rules": {
      "get": {
        "operationId": "listRules",
        "summary": "Fetch full rules for a category (JIT, capped)",
        "description": "Call when an agent needs the current evidence-linked rule text for a category before writing or editing content. `category` is required; `limit` caps the per-call result to at most 5 rules (invariant #5 — bulk rule export is not offered, and per-day drip limits apply: Pro 150, Team 1500 rules/day). Requires a Pro or Team API key in `Authorization: Bearer <key>`.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "category",
            "in": "query",
            "required": true,
            "description": "Rule category to fetch, e.g. content_template or schema. Missing category returns invalid_json with a fix telling the agent to pass ?category=.",
            "schema": {
              "type": "string",
              "example": "content_template"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Maximum rules to return, capped at 5 (default 5). Values above 5 are clamped down. This is the per-call cap; combined with the per-day rules drip.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 5,
              "default": 5
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ruleset version plus up to `limit` full rules for the requested category, sorted by id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RulesResponse"
                }
              }
            }
          },
          "400": {
            "description": "invalid_json — missing required `category` query parameter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "401": {
            "description": "invalid_api_key — missing/unknown Authorization header.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "402": {
            "description": "subscription_past_due — the key's subscription is past due.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "403": {
            "description": "requires_pro — a free/keyless caller must pass a Pro or Team key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "description": "rate_limited — the per-day rules drip for this key is exhausted for today.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "500": {
            "description": "internal_error — ruleset could not be loaded.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key, e.g. `Authorization: Bearer ag_live_...`. Required for GET /api/checks and GET /api/rules; optional on POST /api/scan (omit for aggregate output, pass a Pro/Team key for per-check detail). Keys are shown once at https://aeogeo.site."
      }
    },
    "schemas": {
      "ScanRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "pattern": "^https?://",
            "description": "Public http(s) URL to scan. Private/loopback ranges are refused (SSRF guard)."
          }
        }
      },
      "FreeScanOutput": {
        "type": "object",
        "required": [
          "ok",
          "url",
          "ruleset_version",
          "content_types",
          "groups",
          "overall",
          "findings",
          "upgrade",
          "cached"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Canonical scanned URL."
          },
          "ruleset_version": {
            "type": "integer",
            "description": "Ruleset version used for the scan."
          },
          "content_types": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "blog",
                "landing",
                "product",
                "faq"
              ]
            },
            "description": "What the page was inferred to be. Checks whose applies_to matches none of these are skipped, not failed. Empty means the type could not be determined, in which case every check runs."
          },
          "groups": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/FreeGroupGrade"
            },
            "description": "Per-check-group letter grade and counts. No per-check detail."
          },
          "overall": {
            "$ref": "#/components/schemas/FreeGroupGrade"
          },
          "findings": {
            "type": "array",
            "description": "Failed checks at readiness-ladder levels L0-L3 (the free fundamentals) and how to fix each. Never includes rule IDs, evidence, severity, or anything above L3.",
            "items": {
              "type": "object",
              "required": [
                "check_id",
                "group",
                "level",
                "message",
                "fix"
              ],
              "properties": {
                "check_id": {
                  "type": "string"
                },
                "group": {
                  "type": "string"
                },
                "level": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 3
                },
                "message": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "fix": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          },
          "upgrade": {
            "type": "string",
            "format": "uri",
            "description": "Upgrade pointer surfaced for keyless scans (https://aeogeo.site/pricing)."
          },
          "cached": {
            "type": "boolean",
            "description": "True when the result came from the 1h KV cache."
          }
        }
      },
      "FreeGroupGrade": {
        "type": "object",
        "required": [
          "grade",
          "failed",
          "total"
        ],
        "properties": {
          "grade": {
            "type": "string",
            "enum": [
              "A",
              "B",
              "C",
              "D",
              "F",
              "not_evaluated"
            ],
            "description": "Letter grade, or not_evaluated when the ruleset ships no checks for this group."
          },
          "failed": {
            "type": "integer",
            "minimum": 0,
            "description": "Failing checks in this group."
          },
          "total": {
            "type": "integer",
            "minimum": 0,
            "description": "Non-skipped checks in this group."
          }
        }
      },
      "KeyedScanOutput": {
        "type": "object",
        "required": [
          "ok",
          "url",
          "ruleset_version",
          "content_types",
          "content_type_signals",
          "overall",
          "groups",
          "checks",
          "upgrade",
          "cached"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "ruleset_version": {
            "type": "integer"
          },
          "content_types": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "blog",
                "landing",
                "product",
                "faq"
              ]
            },
            "description": "What the page was inferred to be. Checks whose applies_to matches none of these are skipped, not failed. Empty means the type could not be determined, in which case every check runs."
          },
          "content_type_signals": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Which signals decided content_types, e.g. jsonld:Product, path:root."
          },
          "overall": {
            "$ref": "#/components/schemas/FreeGroupGrade"
          },
          "groups": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/FreeGroupGrade"
            }
          },
          "checks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/KeyedCheckOutput"
            },
            "description": "Per-check results with rule_id, severity, message, fix_hint, evidence."
          },
          "upgrade": {
            "type": "string",
            "format": "uri"
          },
          "cached": {
            "type": "boolean"
          }
        }
      },
      "KeyedCheckOutput": {
        "type": "object",
        "required": [
          "check_id",
          "rule_id",
          "group",
          "status",
          "severity",
          "message",
          "fix_hint",
          "evidence"
        ],
        "properties": {
          "check_id": {
            "type": "string",
            "description": "Stable check descriptor id."
          },
          "rule_id": {
            "type": "string",
            "description": "Id of the rule backing this check (may be empty)."
          },
          "group": {
            "type": "string",
            "description": "Check group the rule belongs to (FR-SCAN-2)."
          },
          "status": {
            "type": "string",
            "enum": [
              "pass",
              "fail",
              "skipped"
            ]
          },
          "severity": {
            "type": "string",
            "enum": [
              "error",
              "warn"
            ],
            "nullable": true
          },
          "message": {
            "type": "string",
            "nullable": true,
            "description": "Failure message when status is fail/skipped."
          },
          "fix_hint": {
            "type": "string",
            "nullable": true,
            "description": "Executable fix hint for a failing check."
          },
          "evidence": {
            "type": "object",
            "nullable": true,
            "additionalProperties": {
              "type": "string"
            },
            "description": "Selector/pattern/header location the check asserted against."
          }
        }
      },
      "CheckDescriptor": {
        "type": "object",
        "required": [
          "id",
          "rule_id",
          "engine",
          "target",
          "severity",
          "params",
          "message",
          "fix_hint"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Stable check descriptor id."
          },
          "rule_id": {
            "type": "string",
            "description": "Backing rule id."
          },
          "engine": {
            "type": "string",
            "enum": [
              "regex",
              "dom",
              "jsonld",
              "robots",
              "http",
              "text"
            ]
          },
          "target": {
            "type": "string",
            "enum": [
              "html",
              "text",
              "jsonld",
              "robots_txt",
              "headers"
            ]
          },
          "applies_to": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Content types this check applies to; absent/empty means universal."
          },
          "severity": {
            "type": "string",
            "enum": [
              "error",
              "warn"
            ]
          },
          "params": {
            "type": "object",
            "additionalProperties": true,
            "description": "Engine-specific assertion parameters."
          },
          "message": {
            "type": "string",
            "description": "Message surfaced when the check fails."
          },
          "fix_hint": {
            "type": "string",
            "description": "Executable remediation hint."
          }
        }
      },
      "ChecksResponse": {
        "type": "object",
        "required": [
          "version",
          "checks"
        ],
        "properties": {
          "version": {
            "type": "integer",
            "description": "Ruleset version of the returned descriptors."
          },
          "checks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CheckDescriptor"
            }
          }
        }
      },
      "FullRule": {
        "type": "object",
        "required": [
          "id",
          "slug",
          "status",
          "rule",
          "why",
          "category",
          "audience",
          "confidence",
          "applies_to",
          "implementation",
          "anti_pattern",
          "evidence",
          "checks",
          "remediation",
          "updated"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "candidate",
              "retired"
            ]
          },
          "rule": {
            "type": "string",
            "description": "The rule statement an agent should follow."
          },
          "why": {
            "type": "string",
            "description": "Evidence-backed rationale."
          },
          "category": {
            "type": "string",
            "description": "Rule category (query param on GET /api/rules)."
          },
          "audience": {
            "type": "string"
          },
          "confidence": {
            "type": "string",
            "enum": [
              "high",
              "medium",
              "low"
            ]
          },
          "applies_to": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "implementation": {
            "type": "string",
            "description": "How to implement this rule."
          },
          "anti_pattern": {
            "type": "string",
            "description": "What to avoid."
          },
          "evidence": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "source",
                "type"
              ],
              "properties": {
                "source": {
                  "type": "string",
                  "format": "uri"
                },
                "type": {
                  "type": "string"
                }
              }
            }
          },
          "checks": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Check descriptor ids that enforce this rule."
          },
          "remediation": {
            "type": "string",
            "description": "Executable fix instruction."
          },
          "updated": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RulesResponse": {
        "type": "object",
        "required": [
          "version",
          "rules"
        ],
        "properties": {
          "version": {
            "type": "integer",
            "description": "Ruleset version of the returned rules."
          },
          "rules": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FullRule"
            },
            "description": "At most `limit` rules (max 5 per call, plus per-day drip)."
          }
        }
      },
      "ErrorBody": {
        "type": "object",
        "required": [
          "code",
          "message",
          "fix",
          "docs"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "method_not_allowed",
              "not_found",
              "invalid_json",
              "invalid_email",
              "invalid_url",
              "blocked_url",
              "invalid_api_key",
              "subscription_past_due",
              "invalid_webhook_signature",
              "unauthorized",
              "requires_pro",
              "rate_limited",
              "fetch_failed",
              "payload_too_large",
              "internal_error"
            ],
            "description": "Stable machine-readable code. Documented at https://aeogeo.site/docs/errors#<code>."
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation of the failure."
          },
          "fix": {
            "type": "string",
            "description": "Imperative instruction an agent can execute to correct the failure (FR-ERR-1)."
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "Link to the human-readable page for this error code."
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "ok",
          "error"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": false,
            "description": "Always false for error responses."
          },
          "error": {
            "$ref": "#/components/schemas/ErrorBody"
          }
        }
      }
    }
  }
}
