{
  "openapi": "3.0.3",
  "info": {
    "title": "Findrix Public API",
    "version": "1.0.0",
    "description": "Programmatic access to Findrix audits for trusted partners.\n\n## Authentication\nEvery request must send a provisioned API key as a Bearer token:\n\n```\nAuthorization: Bearer frx_live_xxxxxxxxxxxxxxxxxxxxxxxx\n```\n\nKeys are created self-serve in your account at\n<https://app.findrix.ai/account/api-keys> on any paid plan; the plaintext\nkey is shown once, at creation. A request without a valid, non-revoked key\ngets `401 invalid_api_key`. Audits you create are owned by the user the key\nbelongs to.\n\n## Running an audit\nThe API is **asynchronous**. `POST /api/v1/audits` returns `202` with an\n`id` immediately; poll `GET /api/v1/audits/{id}` until `status` is\n`completed` (or `failed`).\n\n## What a POST runs — decided per site\nAPI access is an account-level entitlement (keys are issued to paying\naccounts). **What a given domain gets is decided by that site's own\nsubscription**, exactly as in the dashboard:\n\n- **Subscribed site** — the full pipeline: the 29-check technical audit plus\n  the autonomous LLM/citation analysis. `completed` means the report is\n  ready; `GET /api/v1/audits/{id}/report` delivers it.\n- **Unsubscribed site, or a domain you haven't added yet** — the 29-check\n  technical audit only, under the free-site limits (one audit per domain\n  per 7 days; 10 new-site scans per account per day). `completed` arrives\n  in well under two minutes and carries the score, checks and\n  recommendations; no report is generated (`GET …/report` → `404\n  report_not_generated`). Subscribe the site in the dashboard to unlock the\n  full pipeline.\n\nA POST for an unsubscribed domain inside its 7-day window does not start a\nnew run: it returns `202` with that domain's latest audit (the same replay\nshape an `Idempotency-Key` retry gets). When nothing exists to replay, the\nlimit surfaces as `429 rate_limited` with `Retry-After` pointing at the end\nof the window.\n\n## Idempotency\nSend an `Idempotency-Key` header on `POST /api/v1/audits` to make retries\nsafe: a reused key replays the original audit instead of starting a new one,\nand the `202` reports that audit's current status. A transient enqueue\nfailure frees the key, so a retry creates a fresh audit.\n\n## Errors\nAll errors share one envelope: `{ \"error\": { \"code\", \"message\" } }`. Clients\nshould branch on the stable `code`, not the human-readable `message`.\n\n## Rate limits\nEach key has generous fixed per-key caps. Over the cap returns `429` with a\n`Retry-After` header (seconds). `X-RateLimit-*` headers are not emitted yet.\n",
    "contact": {
      "name": "Findrix",
      "url": "https://www.findrix.ai"
    }
  },
  "servers": [
    {
      "url": "https://app.findrix.ai",
      "description": "Production"
    },
    {
      "url": "http://localhost:3000",
      "description": "Local development"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Audits",
      "description": "Create and retrieve audits."
    }
  ],
  "paths": {
    "/api/v1/audits": {
      "post": {
        "tags": [
          "Audits"
        ],
        "operationId": "createAudit",
        "summary": "Start an audit",
        "description": "Starts an audit for the given URL and returns immediately with an `id`.\nCalling the API requires a paid account; **which pipeline runs is decided\nby the target site's subscription** (see *What a POST runs* above): a\nsubscribed site gets the 29-check technical audit plus the autonomous\nLLM/citation pipeline and reaches `completed` when the report is ready;\nan unsubscribed or new domain gets the 29-check audit only, under the\nfree-site limits, and `completed` means the checks are in (no report).\nPoll `GET /api/v1/audits/{id}` for status.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StartAuditRequest"
              },
              "examples": {
                "default": {
                  "value": {
                    "url": "https://example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Audit accepted — the pipeline the target site is entitled to has started. On an idempotent replay, or when an unsubscribed domain is inside its 7-day window, the body carries the existing audit's current mapped status instead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StartAuditResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The target URL resolves to a disallowed address (`forbidden_target`), or the calling key's account holds no active subscription (`plan_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      },
      "get": {
        "tags": [
          "Audits"
        ],
        "operationId": "listAudits",
        "summary": "List audits",
        "description": "Returns audits owned by the calling key's user, newest first, with\nkeyset pagination. Follow `next_cursor` while `has_more` is `true`.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/StatusFilter"
          },
          {
            "$ref": "#/components/parameters/DomainFilter"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of audits.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    },
    "/api/v1/audits/{id}": {
      "get": {
        "tags": [
          "Audits"
        ],
        "operationId": "getAudit",
        "summary": "Get an audit",
        "description": "Returns one audit by its `id`. `status` tracks the whole audit pipeline:\n`queued` while collecting, `running` through analysis and citation\ngeneration, `completed` once the report is ready, `failed` on error. The\nbody shape depends on `status`: `queued`/`running` carry only identity\nfields, `failed` adds `error`, and `completed` adds the score, category\nbreakdown, checks, and ranked recommendations. Returns `404` if the audit\ndoes not exist or is not owned by the calling key's user.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/AuditId"
          }
        ],
        "responses": {
          "200": {
            "description": "The audit. `status` tracks the whole pipeline: `queued`/`running` audits carry only identity fields; `completed` means the full report is ready (score, breakdown, checks, recommendations); `failed` adds `error`. Switch the example below to see each shape.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Audit"
                },
                "examples": {
                  "completed": {
                    "summary": "Completed audit (full results)",
                    "description": "A finished audit with the full payload. `checks` and `recommendations` are truncated to a few entries here for brevity; a real response lists all 29 checks, ranked by impact score, and every failed or warned check that still carries a recommendation.",
                    "value": {
                      "id": "aB3kQ7xR2m",
                      "status": "completed",
                      "url": "https://example.com",
                      "domain": "example.com",
                      "created_at": "2026-06-17T09:00:00.000Z",
                      "completed_at": "2026-06-17T09:01:12.000Z",
                      "score": 55,
                      "spa_detected": true,
                      "breakdown": {
                        "access": {
                          "score": 12,
                          "max": 29,
                          "checks": 10
                        },
                        "schema": {
                          "score": 15,
                          "max": 20,
                          "checks": 10
                        },
                        "content": {
                          "score": 8,
                          "max": 13,
                          "checks": 4
                        },
                        "performance": {
                          "score": 7,
                          "max": 14,
                          "checks": 3
                        },
                        "ai_interaction": {
                          "score": 0,
                          "max": 0,
                          "checks": 2
                        }
                      },
                      "category_weights": {
                        "access": 29,
                        "schema": 20,
                        "content": 13,
                        "ai_interaction": 0,
                        "performance": 14
                      },
                      "checks": [
                        {
                          "name": "ai_crawler_access",
                          "category": "access",
                          "status": "fail",
                          "weight": 6,
                          "impact_score": 6,
                          "help_url": "https://www.findrix.ai/help/ai-crawler-access",
                          "details": {
                            "blocked": [
                              {
                                "token": "GPTBot",
                                "vendor": "cloudflare"
                              }
                            ],
                            "ambiguous": []
                          },
                          "recommendation": {
                            "operation": "unblock_crawler",
                            "title": "Allow AI crawlers through your firewall / CDN",
                            "description": "Your edge (WAF/CDN) returned a block to an AI crawler's User-Agent, so assistants like ChatGPT, Claude, and Perplexity can't read your pages. Allowlist the verified AI bots in your firewall's bot rules. Note: this is measured from our IP using each bot's User-Agent — it catches User-Agent filtering, not IP-range allowlisting.",
                            "code_snippet": "# Cloudflare → Security → Bots / WAF: add a skip rule for AI crawlers, e.g.\n# (http.user_agent contains \"GPTBot\") or (http.user_agent contains \"ClaudeBot\")\n#   or (http.user_agent contains \"PerplexityBot\")  →  Action: Skip / Allow\n# Or in your origin/CDN config, exempt these User-Agents from the bot block.\n"
                          }
                        },
                        {
                          "name": "robots_txt_ai_bots",
                          "category": "access",
                          "status": "fail",
                          "weight": 6,
                          "impact_score": 6,
                          "help_url": "https://www.findrix.ai/help/robots-txt-ai-bots",
                          "details": {
                            "blocked": [
                              "PerplexityBot"
                            ],
                            "contentBlocked": [],
                            "partialBlocked": [],
                            "retrievalBlocked": true
                          },
                          "recommendation": {
                            "operation": "edit_robots_txt",
                            "title": "Allow AI crawlers in /robots.txt",
                            "description": "robots.txt is allow-by-default, so AI crawlers are blocked only by an explicit Disallow — you don't need to name each bot. Act only if a Disallow rule keeps crawlers from content you want surfaced in AI answers. Note: Google-Extended is separate — it controls whether Google may use your content to train and ground Gemini and Vertex AI, not whether Google can crawl or index your site.",
                            "code_snippet": null
                          }
                        },
                        {
                          "name": "content_in_raw_html",
                          "category": "access",
                          "status": "fail",
                          "weight": 5,
                          "impact_score": 5,
                          "help_url": "https://www.findrix.ai/help/content-in-raw-html",
                          "details": {
                            "detected": true,
                            "framework": null,
                            "signals": [
                              "empty-root-mount"
                            ],
                            "visibleTextChars": 0
                          },
                          "recommendation": {
                            "operation": "improve_html",
                            "title": "Render content server-side, not only in JavaScript",
                            "description": "Your homepage HTML is a near-empty shell that fills in with JavaScript. Several AI assistants read only the HTML your server sends, so they get little beyond your page title and description; search engines that run JavaScript may still see your content, sometimes after a delay. Serve your key pages as server-rendered or pre-built HTML (SSR or SSG). Bot-only prerendering is a stopgap, not a fix.",
                            "code_snippet": null
                          }
                        },
                        {
                          "name": "page_title",
                          "category": "access",
                          "status": "pass",
                          "weight": 2,
                          "impact_score": 0,
                          "help_url": "https://www.findrix.ai/help/page-title",
                          "details": {
                            "length": 38,
                            "title": "Acme — AI visibility audits for brands"
                          },
                          "recommendation": {
                            "operation": "improve_html",
                            "title": "Write a unique, descriptive <title> for every page",
                            "description": "Every page needs its own descriptive <title>. Missing, duplicate or placeholder titles (under 10 characters, such as \"Home\") weaken how AI assistants identify and cite each page. There is no length limit: search results may cut a long title off, but AI assistants read it in full.",
                            "code_snippet": "<title>Acme — AI visibility audits for brands</title>"
                          }
                        },
                        {
                          "name": "wikidata_entity",
                          "category": "ai_interaction",
                          "status": "warn",
                          "weight": 0,
                          "impact_score": 0,
                          "help_url": "https://www.findrix.ai/help/wikidata-entity",
                          "details": {
                            "status": "absent",
                            "qid": null,
                            "matchedVia": null
                          },
                          "recommendation": {
                            "operation": "external_entity",
                            "title": "Establish a Wikidata entity for your brand",
                            "description": "AI assistants ground brand facts in Wikidata. If no Wikidata item lists your site as its official website (property P856), create one with your name, website, logo and key identifiers so models can recognize and cite you as a real entity.",
                            "code_snippet": null
                          }
                        },
                        {
                          "name": "wikipedia_presence",
                          "category": "ai_interaction",
                          "status": "warn",
                          "weight": 0,
                          "impact_score": 0,
                          "help_url": "https://www.findrix.ai/help/wikipedia-presence",
                          "details": {
                            "status": "absent",
                            "hasArticle": false
                          },
                          "recommendation": null
                        }
                      ],
                      "recommendations": [
                        {
                          "name": "robots_txt_ai_bots",
                          "category": "access",
                          "status": "fail",
                          "weight": 6,
                          "impact_score": 6,
                          "help_url": "https://www.findrix.ai/help/robots-txt-ai-bots",
                          "details": {
                            "blocked": [
                              "PerplexityBot"
                            ],
                            "contentBlocked": [],
                            "partialBlocked": [],
                            "retrievalBlocked": true
                          },
                          "recommendation": {
                            "operation": "edit_robots_txt",
                            "title": "Allow AI crawlers in /robots.txt",
                            "description": "robots.txt is allow-by-default, so AI crawlers are blocked only by an explicit Disallow — you don't need to name each bot. Act only if a Disallow rule keeps crawlers from content you want surfaced in AI answers. Note: Google-Extended is separate — it controls whether Google may use your content to train and ground Gemini and Vertex AI, not whether Google can crawl or index your site.",
                            "code_snippet": null
                          }
                        },
                        {
                          "name": "content_in_raw_html",
                          "category": "access",
                          "status": "fail",
                          "weight": 5,
                          "impact_score": 5,
                          "help_url": "https://www.findrix.ai/help/content-in-raw-html",
                          "details": {
                            "detected": true,
                            "framework": null,
                            "signals": [
                              "empty-root-mount"
                            ],
                            "visibleTextChars": 0
                          },
                          "recommendation": {
                            "operation": "improve_html",
                            "title": "Render content server-side, not only in JavaScript",
                            "description": "Your homepage HTML is a near-empty shell that fills in with JavaScript. Several AI assistants read only the HTML your server sends, so they get little beyond your page title and description; search engines that run JavaScript may still see your content, sometimes after a delay. Serve your key pages as server-rendered or pre-built HTML (SSR or SSG). Bot-only prerendering is a stopgap, not a fix.",
                            "code_snippet": null
                          }
                        }
                      ]
                    }
                  },
                  "pending": {
                    "summary": "Queued or running audit (identity only)",
                    "value": {
                      "id": "aB3kQ7xR2m",
                      "status": "queued",
                      "url": "https://example.com",
                      "domain": "example.com",
                      "created_at": "2026-06-17T09:00:00.000Z"
                    }
                  },
                  "failed": {
                    "summary": "Failed audit (adds error)",
                    "value": {
                      "id": "aB3kQ7xR2m",
                      "status": "failed",
                      "url": "https://example.com",
                      "domain": "example.com",
                      "created_at": "2026-06-17T09:00:00.000Z",
                      "completed_at": "2026-06-17T09:01:00.000Z",
                      "error": "audit_timeout"
                    }
                  },
                  "unreachable": {
                    "summary": "Failed audit — homepage unreachable (site down or not published)",
                    "value": {
                      "id": "aB3kQ7xR2m",
                      "status": "failed",
                      "url": "https://example.com",
                      "domain": "example.com",
                      "created_at": "2026-06-17T09:00:00.000Z",
                      "completed_at": "2026-06-17T09:00:15.000Z",
                      "error": "homepage_unreachable"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    },
    "/api/v1/audits/{id}/report": {
      "get": {
        "tags": [
          "Audits"
        ],
        "operationId": "getAuditReport",
        "summary": "Get a signed download link for the rich report (PDF)",
        "description": "Returns a short-lived **signed download URL** for the branded\nAI-visibility report (`full` or `light`) as a PDF. **Read-only**: this\nendpoint never starts generation. Reports are produced by\n`POST /api/v1/audits` (which runs the full analysis pipeline); once the\naudit's status reaches `completed`, the link is available here. While\nthe pipeline is running this returns `202`; when no report exists for\nthe audit's site — `404 report_not_generated` (start one with\n`POST /api/v1/audits`). The PDF is rendered once per format and cached\nserver-side, so polling is cheap. `404 not_found` if the audit doesn't\nexist or isn't owned by the calling key's user.\n\n> **Breaking change (2026-08-19):** this endpoint no longer triggers\n> generation (\"generate-if-missing\" removed). A request for a report\n> that was never produced now returns `404 report_not_generated`\n> instead of starting the pipeline and returning `202`. The `403\n> plan_required` and `429 rate_limited` responses left with the\n> generation branch — delivery is neither plan-gated nor rate-limited.\n\n> **Breaking change (2026-06-25):** `200` previously streamed the PDF\n> bytes (`application/pdf`). It now returns the JSON envelope below; fetch\n> `url` to download the PDF.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/AuditId"
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "full",
                "light"
              ],
              "default": "full"
            },
            "description": "`full` (complete report) or `light` (metrics + per-engine + quotes)."
          }
        ],
        "responses": {
          "200": {
            "description": "The report is ready. `url` is a time-limited signed link to the PDF\n(served with `Content-Disposition: attachment`); re-request this\nendpoint after `expires_at` to mint a fresh link.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status",
                    "format",
                    "report_id",
                    "url",
                    "expires_at"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "ready"
                      ]
                    },
                    "format": {
                      "type": "string",
                      "enum": [
                        "full",
                        "light"
                      ]
                    },
                    "report_id": {
                      "type": "string",
                      "description": "The autonomous report job id."
                    },
                    "url": {
                      "type": "string",
                      "format": "uri",
                      "description": "Time-limited signed URL to the report PDF."
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time",
                      "description": "Conservative refresh-by time — re-request this endpoint at/after it to mint a fresh link. The URL itself stays valid for a short safety margin beyond this."
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Report is generating; poll until the link returns.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status",
                    "stage",
                    "report_id"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "generating"
                      ]
                    },
                    "stage": {
                      "type": "string",
                      "enum": [
                        "collecting",
                        "competitors_review",
                        "prompts_review",
                        "running",
                        "reporting"
                      ]
                    },
                    "report_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "`not_found` — the audit doesn't exist or isn't owned by the calling\nkey's user. `report_not_generated` — the audit exists but no report\nhas ever been produced for its site (or a stranded pre-run job will\nnever finish); start one with `POST /api/v1/audits`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Report generation failed (`report_failed`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/Internal"
          },
          "503": {
            "$ref": "#/components/responses/Unavailable"
          }
        }
      }
    },
    "/api/v1/openapi.json": {
      "get": {
        "tags": [
          "Audits"
        ],
        "operationId": "getOpenApiDocument",
        "summary": "This document, as JSON",
        "description": "The raw OpenAPI 3.0 document for the public v1 API, generated from\n`openapi.yaml` at build time (`pnpm openapi:build`). No authentication:\nthe document is public, the same file is served statically at\n`/openapi.json` and `/openapi.yaml`.\n",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A Findrix API key created in your account (`/account/api-keys`, paid plans), e.g. `frx_live_…`."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Opaque retry key (≤255 chars). Reusing it replays the original audit.",
        "schema": {
          "type": "string",
          "maxLength": 255
        }
      },
      "StatusFilter": {
        "name": "status",
        "in": "query",
        "required": false,
        "description": "Only return audits in this status.",
        "schema": {
          "$ref": "#/components/schemas/AuditStatus"
        }
      },
      "DomainFilter": {
        "name": "domain",
        "in": "query",
        "required": false,
        "description": "Only return audits for this exact domain.",
        "schema": {
          "type": "string",
          "maxLength": 255
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Page size.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 25
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque pagination cursor from a previous response's `next_cursor`.",
        "schema": {
          "type": "string",
          "maxLength": 512
        }
      },
      "AuditId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The audit's public id (slug).",
        "schema": {
          "type": "string"
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request body or query parameters were invalid.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "invalid_request": {
                "value": {
                  "error": {
                    "code": "invalid_request",
                    "message": "Body must be { url: string }."
                  }
                }
              },
              "invalid_url": {
                "value": {
                  "error": {
                    "code": "invalid_url",
                    "message": "Invalid URL."
                  }
                }
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "invalid_api_key": {
                "value": {
                  "error": {
                    "code": "invalid_api_key",
                    "message": "Missing or invalid API key."
                  }
                }
              }
            }
          }
        }
      },
      "ForbiddenTarget": {
        "description": "The URL resolves to a disallowed (private/loopback/link-local) address.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "forbidden_target": {
                "value": {
                  "error": {
                    "code": "forbidden_target",
                    "message": "URL resolves to a disallowed address."
                  }
                }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "The audit does not exist or is not owned by the caller.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "not_found": {
                "value": {
                  "error": {
                    "code": "not_found",
                    "message": "Audit not found."
                  }
                }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Per-key rate limit reached, or (on `POST /api/v1/audits` for an unsubscribed domain) a free-site limit — `audit_quota_exhausted` (7-day per-domain window) or `onboarding_scan_cap` (daily new-site scans). The `message` names the window.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying. For a free-site limit this points at the end of its window (up to 7 days); per-key windows report 60.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "rate_limited": {
                "value": {
                  "error": {
                    "code": "rate_limited",
                    "message": "Rate limit reached."
                  }
                }
              }
            }
          }
        }
      },
      "Internal": {
        "description": "The audit could not be created due to a server error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "internal": {
                "value": {
                  "error": {
                    "code": "internal",
                    "message": "Could not create the audit."
                  }
                }
              }
            }
          }
        }
      },
      "Unavailable": {
        "description": "The audit service is temporarily unavailable.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "unavailable": {
                "value": {
                  "error": {
                    "code": "unavailable",
                    "message": "Audit service temporarily unavailable."
                  }
                }
              }
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "The single error envelope used by every v1 endpoint.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable error code — branch on this.",
                "enum": [
                  "invalid_request",
                  "invalid_url",
                  "invalid_api_key",
                  "forbidden_target",
                  "not_found",
                  "rate_limited",
                  "internal",
                  "unavailable",
                  "plan_required",
                  "report_failed",
                  "report_not_generated"
                ]
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation (not stable; do not parse)."
              }
            }
          }
        }
      },
      "AuditStatus": {
        "type": "string",
        "enum": [
          "queued",
          "running",
          "failed",
          "completed"
        ],
        "description": "Lifecycle state of an audit."
      },
      "CheckStatus": {
        "type": "string",
        "enum": [
          "pass",
          "warn",
          "fail"
        ]
      },
      "StartAuditRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "description": "The page URL to audit.",
            "example": "https://example.com"
          }
        }
      },
      "StartAuditResponse": {
        "type": "object",
        "required": [
          "id",
          "status",
          "url",
          "domain",
          "created_at",
          "self"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Public id (slug) — use it to poll the audit."
          },
          "status": {
            "$ref": "#/components/schemas/AuditStatus"
          },
          "url": {
            "type": "string"
          },
          "domain": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "self": {
            "type": "string",
            "description": "Relative path to fetch this audit.",
            "example": "/api/v1/audits/abc123"
          }
        }
      },
      "AuditSummary": {
        "type": "object",
        "required": [
          "id",
          "status",
          "url",
          "domain",
          "score",
          "created_at",
          "completed_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/AuditStatus"
          },
          "url": {
            "type": "string"
          },
          "domain": {
            "type": "string"
          },
          "score": {
            "type": "integer",
            "nullable": true,
            "description": "0–100 overall score; null until completed."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "AuditList": {
        "type": "object",
        "required": [
          "data",
          "has_more",
          "next_cursor"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AuditSummary"
            }
          },
          "has_more": {
            "type": "boolean"
          },
          "next_cursor": {
            "type": "string",
            "nullable": true,
            "description": "Pass as `cursor` to fetch the next page; null on the last page."
          }
        }
      },
      "Recommendation": {
        "type": "object",
        "nullable": true,
        "description": "Suggested fix for a failed/warned check, or null when no template exists.",
        "required": [
          "title",
          "description",
          "operation",
          "code_snippet"
        ],
        "properties": {
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "operation": {
            "type": "string",
            "description": "The kind of change the fix represents (e.g. add, edit, configure)."
          },
          "code_snippet": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "Check": {
        "type": "object",
        "required": [
          "name",
          "category",
          "status",
          "weight",
          "impact_score",
          "help_url",
          "details",
          "recommendation"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "category": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/CheckStatus"
          },
          "weight": {
            "type": "number",
            "description": "Contribution to the 0-100 score. 0 for the off-score authority track (ai_interaction) and for checks judged not applicable to the audited site (e.g. FAQPage/Article/BreadcrumbList/dateModified on a site with no matching surface) — such checks report status \"pass\" with details.applicable = false and are excluded from the score denominator."
          },
          "impact_score": {
            "type": "number",
            "description": "weight × status factor; the ranking key for recommendations."
          },
          "help_url": {
            "type": "string",
            "format": "uri",
            "description": "Absolute URL of the per-check help page on www.findrix.ai."
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "description": "Check-specific evidence (opaque shape)."
          },
          "recommendation": {
            "$ref": "#/components/schemas/Recommendation"
          }
        }
      },
      "CategoryBreakdown": {
        "type": "object",
        "description": "Per-category score rollup, keyed by category name.",
        "additionalProperties": {
          "type": "object",
          "required": [
            "score",
            "max",
            "checks"
          ],
          "properties": {
            "score": {
              "type": "number"
            },
            "max": {
              "type": "number"
            },
            "checks": {
              "type": "integer"
            }
          }
        }
      },
      "AuditPending": {
        "type": "object",
        "description": "An audit that is still queued or running.",
        "required": [
          "id",
          "status",
          "url",
          "domain",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running"
            ]
          },
          "url": {
            "type": "string"
          },
          "domain": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AuditFailed": {
        "type": "object",
        "description": "An audit that ran but failed.",
        "required": [
          "id",
          "status",
          "url",
          "domain",
          "created_at",
          "completed_at",
          "error"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "failed"
            ]
          },
          "url": {
            "type": "string"
          },
          "domain": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "error": {
            "type": "string",
            "nullable": true,
            "description": "Stable failure sentinel. Known values: `run_failed` (internal error), `audit_timeout` (the scan exceeded its budget), `homepage_unreachable` (the homepage returned no usable HTML — the site may be down, unpublished, or answering with errors), `waf_blocked:<vendor>` (a web application firewall blocked the scanner), `enqueue_failed` (the scan could not be queued), `kickoff_failed` (the audit was created but its pipeline failed to start). Not a closed enum — new sentinels may be added; treat unknown values as a generic failure."
          }
        }
      },
      "AuditCompleted": {
        "type": "object",
        "description": "A finished audit with full results.",
        "required": [
          "id",
          "status",
          "url",
          "domain",
          "created_at",
          "completed_at",
          "score",
          "spa_detected",
          "breakdown",
          "category_weights",
          "checks",
          "recommendations"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "completed"
            ]
          },
          "url": {
            "type": "string"
          },
          "domain": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "score": {
            "type": "integer",
            "description": "0–100 overall score."
          },
          "spa_detected": {
            "type": "boolean",
            "description": "True when the homepage's server-sent HTML is a near-empty client-rendered shell; the same verdict as the content_in_raw_html check failing. Judged on the raw HTML only: fewer than 200 characters of visible body text, plus either an empty SPA mount root (#root, #app, #__next, #__nuxt) or at least two shell signals (low text-to-markup ratio, heavy script payload, framework bootstrap data). The value is stored when the audit completes and is not recomputed when the heuristic changes, so older audits keep the verdict of the rule they were judged by."
          },
          "breakdown": {
            "$ref": "#/components/schemas/CategoryBreakdown"
          },
          "category_weights": {
            "type": "object",
            "additionalProperties": {
              "type": "number"
            }
          },
          "checks": {
            "type": "array",
            "description": "All checks, ordered for the report.",
            "items": {
              "$ref": "#/components/schemas/Check"
            }
          },
          "recommendations": {
            "type": "array",
            "description": "Failed/warned checks ranked by impact.",
            "items": {
              "$ref": "#/components/schemas/Check"
            }
          }
        }
      },
      "Audit": {
        "oneOf": [
          {
            "$ref": "#/components/schemas/AuditCompleted"
          },
          {
            "$ref": "#/components/schemas/AuditFailed"
          },
          {
            "$ref": "#/components/schemas/AuditPending"
          }
        ],
        "discriminator": {
          "propertyName": "status",
          "mapping": {
            "queued": "#/components/schemas/AuditPending",
            "running": "#/components/schemas/AuditPending",
            "failed": "#/components/schemas/AuditFailed",
            "completed": "#/components/schemas/AuditCompleted"
          }
        }
      }
    }
  }
}
