{
  "openapi": "3.1.0",
  "info": {
    "title": "Sitedex API",
    "version": "1.0.0",
    "summary": "The live web index for AI agents.",
    "description": "Sitedex crawls public websites, scores how well they answer real buyer questions, and serves the result over a versioned REST API, MCP, and a CLI. Public reads are free. Audits cost 1 credit. Anyone can index a new site for free, no account needed; signed-in callers spend a credit. New accounts start with 1 free credit.\n\n## Rate limits\n\nEvery rate-limited response carries four standard headers so batch processors can pace themselves without hitting 429s:\n\n- `X-RateLimit-Limit` — total requests allowed in the current window.\n- `X-RateLimit-Remaining` — requests remaining (clamped at 0).\n- `X-RateLimit-Reset` — Unix timestamp (seconds) when the window resets.\n- `Retry-After` — seconds to wait before retrying (RFC 6585 §4). Set only on 429.\n\n### Per-endpoint caps\n\n- `POST /v1/audits` (anon, per IP): **10 / hour** burst + **30 / day** slow-burn. Authed callers gate on credit balance instead — no per-IP cap. Headers report the hourly window.\n- `POST /v1/credits/purchase` and the legacy `POST /billing/payg/checkout` (per org): **10 / hour** Stripe-checkout flood guard.\n- `POST /api/auth/email-otp/send-verification-otp` (per email): **3 / minute** anti-spam guard.\n- All other v1 routes are unmetered today; we'll add caps if abuse appears, and they'll be visible through the same headers.\n\n### Recommended backoff\n\nOn a 429, prefer `Retry-After` if your client supports it; otherwise compute the wait from `X-RateLimit-Reset`. For sustained throughput, watch `X-RateLimit-Remaining` on every 2xx and slow yourself down before the cap. Sample: `if (remaining < 2) sleep(reset - now)`.",
    "contact": { "name": "Sitedex", "email": "hello@sitedex.dev", "url": "https://sitedex.dev" },
    "license": { "name": "Proprietary", "url": "https://sitedex.dev/terms" }
  },
  "servers": [
    { "url": "https://api.sitedex.dev", "description": "Production" }
  ],
  "externalDocs": {
    "description": "Full docs",
    "url": "https://sitedex.dev/docs"
  },
  "tags": [
    { "name": "Audits", "description": "Run an audit, fetch one, list history." },
    { "name": "Sites", "description": "Site profiles and reports — public reads." },
    { "name": "Search", "description": "Cross-site semantic search." },
    { "name": "Credits", "description": "Wallet, ledger, and Stripe Checkout." },
    { "name": "Account", "description": "Identity, API keys, and CLI device-flow sign-in." }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "sdx_…",
        "description": "API key from app.sitedex.dev/settings/api-keys, or `sitedex login` in the CLI."
      },
      "apiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "Alternate header for API-key auth. Same key as bearerAuth."
      },
      "cookieAuth": {
        "type": "apiKey",
        "in": "cookie",
        "name": "better-auth.session_token",
        "description": "Browser session cookie. Required for endpoints under `/v1/auth/tokens` (a key cannot mint other keys)."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["type", "code", "message"],
            "properties": {
              "type": {
                "type": "string",
                "enum": ["invalid_request_error", "authentication_error", "rate_limit_error", "rate_limited", "insufficient_credits", "api_error"],
                "description": "Stable error class. Branch on `code` for finer logic."
              },
              "code": {
                "type": "string",
                "example": "balance_zero",
                "description": "Stable machine-readable code."
              },
              "message": {
                "type": "string",
                "example": "You're out of credits. Buy a credit pack to keep going.",
                "description": "Human-readable text. Subject to wording changes."
              },
              "param": {
                "type": "string",
                "description": "Field name when the error is about a single input."
              }
            }
          }
        }
      },
      "Timestamp": {
        "type": "string",
        "description": "Timestamp string. Most fields use SQLite datetime format (`YYYY-MM-DD HH:MM:SS`, UTC). Some (notably `started_at` on audits) use ISO 8601. Consumers should accept both.",
        "examples": ["2026-05-06 15:46:35", "2026-05-06T15:46:36.476Z"]
      },
      "Audit": {
        "type": "object",
        "required": ["id", "domain", "url", "status", "created_at", "started_at", "completed_at", "pages_crawled", "scores", "credits_charged", "failure_reason", "status_url"],
        "properties": {
          "id": { "type": "string", "example": "mou8cky7beqsr72k" },
          "domain": { "type": "string", "example": "doma.xyz" },
          "url": { "type": "string", "format": "uri", "example": "https://doma.xyz" },
          "status": { "type": "string", "enum": ["queued", "running", "completed", "failed", "expired"] },
          "created_at": { "$ref": "#/components/schemas/Timestamp" },
          "started_at": { "anyOf": [{ "$ref": "#/components/schemas/Timestamp" }, { "type": "null" }] },
          "completed_at": { "anyOf": [{ "$ref": "#/components/schemas/Timestamp" }, { "type": "null" }] },
          "pages_crawled": { "type": "integer", "minimum": 0, "description": "Always present; 0 when nothing has been crawled yet." },
          "scores": {
            "type": "object",
            "required": ["composite", "protocol", "content"],
            "properties": {
              "composite": { "type": ["integer", "null"], "minimum": 0, "maximum": 100 },
              "content": { "type": ["integer", "null"], "minimum": 0, "maximum": 100 },
              "protocol": { "type": ["integer", "null"], "minimum": 0, "maximum": 100 }
            }
          },
          "credits_charged": { "type": "integer", "enum": [0, 1], "description": "1 when the calling org was charged for this audit, 0 for anon submissions or already-indexed reuses." },
          "failure_reason": { "type": ["string", "null"], "description": "Set when status='failed'. Owners see the raw reason; non-owners see a scrubbed value." },
          "status_url": { "type": "string", "format": "uri", "description": "Polling URL for this audit." }
        }
      },
      "Site": {
        "type": "object",
        "required": ["domain", "url", "status", "page_count", "item_count", "analyzed_at"],
        "properties": {
          "domain": { "type": "string", "example": "doma.xyz" },
          "url": { "type": "string", "format": "uri" },
          "status": { "type": "string", "enum": ["pending", "analyzing", "extracting", "active", "failed", "unavailable"] },
          "analyzed_at": { "anyOf": [{ "$ref": "#/components/schemas/Timestamp" }, { "type": "null" }] },
          "page_count": { "type": "integer" },
          "item_count": { "type": "integer" },
          "created_at": { "$ref": "#/components/schemas/Timestamp", "description": "Only returned by `GET /v1/sites` (claimed-sites listing)." }
        }
      },
      "AuditCreateResponse": {
        "type": "object",
        "required": ["audit", "reused", "credits_remaining"],
        "properties": {
          "audit": { "$ref": "#/components/schemas/Audit" },
          "reused": { "type": "boolean", "description": "True when the site was already indexed and the existing report was returned without a fresh crawl. No credit charged. Pass `force: true` on the request to crawl again." },
          "credits_remaining": { "type": ["integer", "null"], "description": "Wallet balance after the call. `null` for anon callers." }
        }
      },
      "Level": {
        "type": "object",
        "required": ["tier", "name"],
        "properties": {
          "tier": { "type": "integer" },
          "name": { "type": "string", "example": "Basic Presence" }
        }
      },
      "ReportEvaluation": {
        "type": "object",
        "required": ["id", "started_at", "completed_at", "model", "total_questions", "answered_yes", "answered_partial", "answered_no", "diagnosis_counts", "scoring_version", "content_score", "protocol_score", "protocol_items_passed", "protocol_items_total", "composite_score", "prompt_version", "level"],
        "properties": {
          "id": { "type": "string" },
          "started_at": { "type": "integer", "description": "Unix milliseconds." },
          "completed_at": { "type": ["integer", "null"], "description": "Unix milliseconds. Null when the evaluation hasn't finished." },
          "model": { "type": ["string", "null"] },
          "total_questions": { "type": "integer" },
          "answered_yes": { "type": "integer" },
          "answered_partial": { "type": "integer" },
          "answered_no": { "type": "integer" },
          "diagnosis_counts": {
            "type": "object",
            "additionalProperties": { "type": "integer" },
            "description": "Count of results per diagnosis bucket."
          },
          "scoring_version": { "type": "integer" },
          "content_score": { "type": ["integer", "null"], "minimum": 0, "maximum": 100 },
          "protocol_score": { "type": ["integer", "null"], "minimum": 0, "maximum": 100 },
          "protocol_items_passed": { "type": ["integer", "null"] },
          "protocol_items_total": { "type": ["integer", "null"] },
          "composite_score": { "type": ["integer", "null"], "minimum": 0, "maximum": 100 },
          "prompt_version": { "type": ["string", "null"] },
          "level": { "anyOf": [{ "$ref": "#/components/schemas/Level" }, { "type": "null" }] }
        }
      },
      "ReportResult": {
        "type": "object",
        "required": ["question_id", "question", "topic", "importance", "answered", "answer", "confidence", "diagnosis", "fix_hint", "citations", "chunks_considered", "top_chunk_score", "inference_used", "reasoning_trace", "question_weight"],
        "properties": {
          "question_id": { "type": "string" },
          "question": { "type": "string" },
          "topic": { "type": ["string", "null"] },
          "importance": { "type": ["string", "null"], "enum": ["high", "medium", "low", null] },
          "answered": { "type": "string", "description": "Expected: `yes` | `partial` | `no`." },
          "answer": { "type": ["string", "null"], "description": "The judge's free-text answer when one exists." },
          "confidence": { "type": "string", "description": "Expected: `high` | `medium` | `low`." },
          "diagnosis": { "type": "string", "description": "Expected: `answered` | `vague_claim` | `collapsed_content` | `missing_page` | `contradictory_pages` | `genuinely_absent`." },
          "fix_hint": { "type": ["string", "null"] },
          "citations": { "type": "array", "items": { "type": "object" } },
          "chunks_considered": { "type": "integer" },
          "top_chunk_score": { "type": ["number", "null"] },
          "inference_used": { "type": ["string", "null"], "description": "`stated` | `synthesized` | `world_knowledge`." },
          "reasoning_trace": { "type": ["string", "null"] },
          "question_weight": { "type": ["number", "null"] }
        }
      },
      "ReportProtocolItem": {
        "type": "object",
        "required": ["id", "category", "passed", "weight"],
        "properties": {
          "id": { "type": "string" },
          "category": { "type": "string" },
          "passed": { "type": "boolean" },
          "weight": { "type": "number" }
        }
      },
      "ReportProtocol": {
        "type": "object",
        "required": ["signals", "items", "score", "passed", "total"],
        "properties": {
          "signals": { "type": "object", "description": "Raw protocol signals as detected at crawl time (robots.txt, sitemap, llms.txt, AI-bot allow rules, MCP card, WebMCP, OpenAPI, etc.)." },
          "items": { "type": "array", "items": { "$ref": "#/components/schemas/ReportProtocolItem" } },
          "score": { "type": "integer", "minimum": 0, "maximum": 100 },
          "passed": { "type": "integer" },
          "total": { "type": "integer" }
        }
      },
      "ReportDiscovery": {
        "type": "object",
        "required": ["has_robots_txt", "has_sitemap", "has_llms_txt", "robots_txt_url", "sitemap_url", "llms_txt_url"],
        "properties": {
          "has_robots_txt": { "type": "boolean" },
          "has_sitemap": { "type": "boolean" },
          "has_llms_txt": { "type": "boolean" },
          "robots_txt_url": { "type": ["string", "null"], "format": "uri" },
          "sitemap_url": { "type": ["string", "null"], "format": "uri" },
          "llms_txt_url": { "type": ["string", "null"], "format": "uri" }
        }
      },
      "Report": {
        "type": "object",
        "required": ["domain", "evaluation", "protocol", "results", "capabilities", "discovery"],
        "properties": {
          "domain": { "type": "string" },
          "evaluation": { "$ref": "#/components/schemas/ReportEvaluation" },
          "protocol": { "anyOf": [{ "$ref": "#/components/schemas/ReportProtocol" }, { "type": "null" }] },
          "results": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResult" } },
          "capabilities": { "type": "array", "items": { "type": "object" }, "description": "Detected product capabilities (commerce APIs, OAuth flows, etc.)." },
          "discovery": { "$ref": "#/components/schemas/ReportDiscovery" }
        }
      },
      "CreditsBalance": {
        "type": "object",
        "required": ["balance", "lifetime_added", "lifetime_spent"],
        "properties": {
          "balance": { "type": "integer", "minimum": 0 },
          "lifetime_added": { "type": "integer" },
          "lifetime_spent": { "type": "integer" }
        }
      },
      "LedgerEntry": {
        "type": "object",
        "required": ["id", "delta", "balance_after", "kind", "ref_type", "ref_id", "metadata", "created_at"],
        "properties": {
          "id": { "type": "string" },
          "delta": { "type": "integer", "description": "Positive for credits added, negative for spend." },
          "balance_after": { "type": "integer" },
          "kind": { "type": "string", "enum": ["purchase", "purchase_refund", "welcome_grant", "audit_reserve", "audit_commit", "audit_refund", "admin_adjust"] },
          "ref_type": { "type": ["string", "null"] },
          "ref_id": { "type": ["string", "null"] },
          "metadata": { "description": "Free-form structured metadata. May be null. Shape depends on `kind`." },
          "created_at": { "$ref": "#/components/schemas/Timestamp" }
        }
      },
      "PurchaseResponse": {
        "type": "object",
        "required": ["checkout_url", "stripe_session_id", "pack", "credits", "amount_total", "currency"],
        "properties": {
          "checkout_url": { "type": ["string", "null"], "format": "uri", "description": "Redirect the user here. Null on the (rare) Stripe path that returns no URL." },
          "stripe_session_id": { "type": "string" },
          "pack": { "type": "string", "enum": ["single", "starter", "pro"], "description": "Canonical pack id (legacy `payg-1` resolved to `single`)." },
          "credits": { "type": "integer", "description": "Credits this purchase will grant when the webhook completes.", "example": 15 },
          "amount_total": { "type": "integer", "description": "Cents — matches the configured Stripe price for the pack.", "example": 5000 },
          "currency": { "type": "string", "example": "usd" }
        }
      },
      "WhoAmI": {
        "type": "object",
        "required": ["user_id", "email", "org_id", "via", "api_key_id"],
        "properties": {
          "user_id": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "org_id": { "type": "string" },
          "via": { "type": "string", "enum": ["cookie", "api_key"] },
          "api_key_id": { "type": ["string", "null"], "description": "Set when `via='api_key'`." }
        }
      },
      "ApiKey": {
        "type": "object",
        "required": ["id", "label", "prefix", "created_at"],
        "properties": {
          "id": { "type": "string" },
          "label": { "type": "string" },
          "prefix": { "type": "string", "example": "sdx_" },
          "starts_with": { "type": ["string", "null"] },
          "created_at": { "$ref": "#/components/schemas/Timestamp" },
          "last_used_at": { "anyOf": [{ "$ref": "#/components/schemas/Timestamp" }, { "type": "null" }] },
          "expires_at": { "anyOf": [{ "$ref": "#/components/schemas/Timestamp" }, { "type": "null" }] }
        }
      },
      "SearchResult": {
        "type": "object",
        "required": ["site_id", "domain", "site_name", "source", "type", "text", "heading_path", "url", "score"],
        "properties": {
          "site_id": { "type": "string" },
          "domain": { "type": "string" },
          "site_name": { "type": "string" },
          "source": { "type": "string", "example": "chunk" },
          "type": { "type": "string", "example": "chunk" },
          "text": { "type": "string", "description": "Markdown excerpt." },
          "heading_path": { "type": "array", "items": { "type": "string" } },
          "url": { "type": "string", "format": "uri" },
          "score": { "type": "number" }
        }
      },
      "Page": {
        "type": "object",
        "description": "Stored page row. Field set is the literal `SELECT * FROM pages` shape.",
        "required": ["site_id", "path", "url", "title", "markdown"],
        "properties": {
          "site_id": { "type": "string" },
          "path": { "type": "string", "example": "/pricing" },
          "url": { "type": "string", "format": "uri" },
          "title": { "type": ["string", "null"] },
          "markdown": { "type": "string" }
        },
        "additionalProperties": true
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Authentication required or invalid.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Forbidden": {
        "description": "Authenticated but not allowed.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "Resource not found, or the caller doesn't have access.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Rate-limited. `Retry-After` indicates the wait; `X-RateLimit-*` describe the window. See the top-level `Rate limits` section in the API description for endpoint caps and recommended backoff.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/XRateLimitLimit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/XRateLimitRemaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/XRateLimitReset"
          }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "InsufficientCredits": {
        "description": "Out of credits.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "headers": {
      "RetryAfter": {
        "description": "RFC 6585 §4. Seconds the client should wait before retrying. Set on every 429 response.",
        "schema": { "type": "integer", "minimum": 1 },
        "example": 3600
      },
      "XRateLimitLimit": {
        "description": "Total requests allowed in the current window (GitHub/Stripe-style informational header).",
        "schema": { "type": "integer", "minimum": 1 },
        "example": 10
      },
      "XRateLimitRemaining": {
        "description": "Requests remaining in the current window for this client. Always ≥ 0.",
        "schema": { "type": "integer", "minimum": 0 },
        "example": 7
      },
      "XRateLimitReset": {
        "description": "Unix timestamp (seconds) when the window resets. Conservative upper bound — the actual reset may come sooner if older entries fall off the rolling window.",
        "schema": { "type": "integer" },
        "example": 1700003600
      }
    }
  },
  "paths": {
    "/v1/audits": {
      "get": {
        "tags": ["Audits"],
        "summary": "List your audits",
        "description": "Audits run by your organization, newest first. No cursor pagination; pass `limit` (default 50, max 200).",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } }
        ],
        "responses": {
          "200": {
            "description": "Audit list.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["audits"],
              "properties": {
                "audits": { "type": "array", "items": { "$ref": "#/components/schemas/Audit" } }
              }
            } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["Audits"],
        "summary": "Run an audit",
        "description": "Spends 1 credit when signed in. Submitting a site that's already in the index returns the existing report with `reused: true` and no charge — pass `force: true` to crawl again. Anonymous calls require a Turnstile token via the `x-turnstile-token` header.",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }, {}],
        "parameters": [
          { "name": "Idempotency-Key", "in": "header", "schema": { "type": "string", "minLength": 1, "maxLength": 255 }, "description": "Stripe-style idempotency. Same key + same body returns the cached response. Same key + different body returns 409." },
          { "name": "x-turnstile-token", "in": "header", "schema": { "type": "string" }, "description": "Required for anonymous calls (browser only)." }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["url"],
            "properties": {
              "url": { "type": "string", "example": "https://example.com" },
              "force": { "type": "boolean", "default": false, "description": "Run a fresh audit even if the site is already indexed. Charges another credit." }
            }
          } } }
        },
        "responses": {
          "200": {
            "description": "Audit submitted (or cached audit returned).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuditCreateResponse" } } }
          },
          "400": { "description": "Invalid input.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "402": { "$ref": "#/components/responses/InsufficientCredits" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "description": "Idempotency conflict.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/audits/{id}": {
      "get": {
        "tags": ["Audits"],
        "summary": "Fetch one audit",
        "description": "Returns the audit if you submitted it or you've verified ownership of the domain. 404 to anyone else.",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": {
            "description": "Audit detail.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["audit"],
              "properties": { "audit": { "$ref": "#/components/schemas/Audit" } }
            } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/sites": {
      "get": {
        "tags": ["Sites"],
        "summary": "Sites you've claimed",
        "description": "Cursor-paginated. The cursor is the `created_at` of the last row.",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
          { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "`created_at` of the last row from the previous page." }
        ],
        "responses": {
          "200": {
            "description": "Claimed sites.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["sites", "next_cursor"],
              "properties": {
                "sites": { "type": "array", "items": { "$ref": "#/components/schemas/Site" } },
                "next_cursor": { "type": ["string", "null"] }
              }
            } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/sites/{domain}": {
      "get": {
        "tags": ["Sites"],
        "summary": "Site profile and headline score",
        "description": "Public read — no auth required. Returns the latest completed audit summary alongside the site profile. `latest_audit.failure_reason` is scrubbed for non-owners.",
        "parameters": [{ "name": "domain", "in": "path", "required": true, "schema": { "type": "string" }, "example": "doma.xyz" }],
        "responses": {
          "200": {
            "description": "Site + latest audit.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["site", "latest_audit"],
              "properties": {
                "site": { "$ref": "#/components/schemas/Site" },
                "latest_audit": { "anyOf": [{ "$ref": "#/components/schemas/Audit" }, { "type": "null" }] }
              }
            } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/sites/{domain}/report": {
      "get": {
        "tags": ["Sites"],
        "summary": "Full audit report",
        "description": "Per-question diagnosis, capabilities, protocol checklist, and discovery files. Public read.",
        "parameters": [{ "name": "domain", "in": "path", "required": true, "schema": { "type": "string" }, "example": "doma.xyz" }],
        "responses": {
          "200": {
            "description": "Full report.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Report" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/sites/{domain}/audits": {
      "get": {
        "tags": ["Sites"],
        "summary": "Audit history for a site",
        "description": "Verified domain owners see every audit (`scope: 'all'`). Otherwise scoped to the calling org (`scope: 'org'`).",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
        "parameters": [
          { "name": "domain", "in": "path", "required": true, "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } }
        ],
        "responses": {
          "200": {
            "description": "Audit history.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["audits", "scope"],
              "properties": {
                "audits": { "type": "array", "items": { "$ref": "#/components/schemas/Audit" } },
                "scope": { "type": "string", "enum": ["all", "org"], "description": "`all` for verified owners; `org` otherwise." }
              }
            } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/credits/balance": {
      "get": {
        "tags": ["Credits"],
        "summary": "Wallet snapshot",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
        "responses": {
          "200": {
            "description": "Wallet balance and lifetime totals.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreditsBalance" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/credits/ledger": {
      "get": {
        "tags": ["Credits"],
        "summary": "Transaction history",
        "description": "Cursor-paginated. The cursor is the `created_at` of the last row.",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
          { "name": "cursor", "in": "query", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Recent transactions.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["transactions", "next_cursor"],
              "properties": {
                "transactions": { "type": "array", "items": { "$ref": "#/components/schemas/LedgerEntry" } },
                "next_cursor": { "type": ["string", "null"] }
              }
            } } }
          }
        }
      }
    },
    "/v1/credits/purchase": {
      "post": {
        "tags": ["Credits"],
        "summary": "Buy a credit pack",
        "description": "Returns a Stripe Checkout URL for one of the credit packs. `single` ($5 → 1 credit) is the default. `starter` ($50 → 15 credits, 33% off). `pro` ($100 → 40 credits, 50% off). Credits never expire. Legacy `payg-1` is accepted as an alias for `single`.",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": {
            "type": "object",
            "properties": {
              "pack": {
                "type": "string",
                "enum": ["single", "starter", "pro", "payg-1"],
                "default": "single",
                "description": "Pack id. `payg-1` accepted as a legacy alias for `single`."
              }
            }
          } } }
        },
        "responses": {
          "200": {
            "description": "Stripe Checkout URL.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PurchaseResponse" } } }
          },
          "400": { "description": "Unknown pack.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "503": { "description": "Pack is valid but not configured in this environment.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/v1/auth/whoami": {
      "get": {
        "tags": ["Account"],
        "summary": "Current identity",
        "description": "Returns the signed-in user, organization, and how the caller was identified.",
        "security": [{ "bearerAuth": [] }, { "apiKeyAuth": [] }],
        "responses": {
          "200": {
            "description": "Identity snapshot.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WhoAmI" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/auth/tokens": {
      "get": {
        "tags": ["Account"],
        "summary": "List API keys",
        "description": "Cookie session required. A key cannot list keys.",
        "security": [{ "cookieAuth": [] }],
        "responses": {
          "200": {
            "description": "Keys for the calling organization.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["keys"],
              "properties": { "keys": { "type": "array", "items": { "$ref": "#/components/schemas/ApiKey" } } }
            } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "tags": ["Account"],
        "summary": "Create an API key",
        "description": "Cookie session required. The plaintext key is returned once and never again.",
        "security": [{ "cookieAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["label"],
            "properties": {
              "label": { "type": "string", "minLength": 1, "maxLength": 32 },
              "expires_in_days": { "type": ["integer", "null"], "minimum": 1, "maximum": 365 }
            }
          } } }
        },
        "responses": {
          "201": {
            "description": "Key created. Save the plaintext now.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["key", "plaintext_key", "warning"],
              "properties": {
                "key": { "$ref": "#/components/schemas/ApiKey" },
                "plaintext_key": { "type": "string", "example": "sdx_…" },
                "warning": { "type": "string" }
              }
            } } }
          },
          "400": { "description": "Invalid input.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/v1/auth/tokens/{id}": {
      "delete": {
        "tags": ["Account"],
        "summary": "Revoke an API key",
        "description": "Cookie session required.",
        "security": [{ "cookieAuth": [] }],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "204": { "description": "Revoked." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/auth/cli/exchange": {
      "get": {
        "tags": ["Account"],
        "summary": "Exchange a sign-in nonce (CLI device flow)",
        "description": "One-shot. The CLI polls this endpoint after `sitedex_login_start`. Returns 404 with `{status: 'pending'}` while the user hasn't clicked Authorize yet, then 200 with the API key once they have.",
        "parameters": [{ "name": "nonce", "in": "query", "required": true, "schema": { "type": "string", "pattern": "^[a-f0-9]{32,128}$" } }],
        "responses": {
          "200": {
            "description": "Authorized — returns the API key. The server deletes the KV record before returning, so this read is one-shot.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["status", "token", "key_id"],
              "properties": {
                "status": { "type": "string", "enum": ["authorized"] },
                "token": { "type": "string", "example": "sdx_…" },
                "key_id": { "type": ["string", "null"] }
              }
            } } }
          },
          "400": { "description": "Invalid nonce.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": {
            "description": "Still pending — user hasn't clicked Authorize. Poll again.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["status"],
              "properties": { "status": { "type": "string", "enum": ["pending"] } }
            } } }
          }
        }
      }
    },
    "/v1/auth/cli/authorize": {
      "post": {
        "tags": ["Account"],
        "summary": "Authorize a CLI sign-in",
        "description": "Browser-side. Called from /cli-auth after the user clicks Authorize. Cookie session required. Creates an API key tied to the calling user + org and stashes the plaintext under the nonce in KV (5-min TTL) for the CLI to claim.",
        "security": [{ "cookieAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["nonce", "label"],
            "properties": {
              "nonce": { "type": "string", "pattern": "^[a-f0-9]{32,128}$" },
              "label": { "type": "string", "minLength": 1, "maxLength": 32 }
            }
          } } }
        },
        "responses": {
          "200": {
            "description": "Sign-in approved. The CLI's polling exchange call will receive the key.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["ok", "replay"],
              "properties": {
                "ok": { "type": "boolean" },
                "replay": { "type": "boolean", "description": "True if this nonce had already been authorized." },
                "key_id": { "type": "string", "description": "Set on the first authorization (replay=false)." }
              }
            } } }
          },
          "400": { "description": "Invalid input.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/search": {
      "post": {
        "tags": ["Search"],
        "summary": "Search the index",
        "description": "Cross-site semantic search. Public — no auth required. The same surface (POST or GET) is also reachable from the MCP `sitedex_search` tool and the CLI `sitedex search` command.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["q"],
            "properties": {
              "q": { "type": "string", "description": "The search query." },
              "site": { "type": "string", "description": "Domain or slug. Scopes to one site." },
              "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 20, "description": "Max results to return. Capped at 50 because Vectorize's topK is 50 when full metadata is requested." }
            }
          } } }
        },
        "responses": {
          "200": {
            "description": "Search results.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["query", "results", "total", "sites_searched"],
              "properties": {
                "query": { "type": "string" },
                "results": { "type": "array", "items": { "$ref": "#/components/schemas/SearchResult" } },
                "total": { "type": "integer" },
                "sites_searched": { "type": "integer" }
              }
            } } }
          },
          "400": { "description": "Missing `q`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      },
      "get": {
        "tags": ["Search"],
        "summary": "Search the index (GET form)",
        "description": "Same as POST `/search`. Useful for quick links.",
        "parameters": [
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "site", "in": "query", "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } }
        ],
        "responses": {
          "200": {
            "description": "Search results.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["query", "results", "total", "sites_searched"],
              "properties": {
                "query": { "type": "string" },
                "results": { "type": "array", "items": { "$ref": "#/components/schemas/SearchResult" } },
                "total": { "type": "integer" },
                "sites_searched": { "type": "integer" }
              }
            } } }
          },
          "400": { "description": "Missing `q`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/sites/{domain}/pages/{path}": {
      "get": {
        "tags": ["Sites"],
        "summary": "Fetch one page as markdown",
        "description": "Public read. The path is URL-encoded with the leading slash. Falls back to a prefix match when an exact path isn't found.",
        "parameters": [
          { "name": "domain", "in": "path", "required": true, "schema": { "type": "string" } },
          { "name": "path", "in": "path", "required": true, "schema": { "type": "string" }, "example": "%2Fpricing" }
        ],
        "responses": {
          "200": {
            "description": "Page row.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["page"],
              "properties": { "page": { "$ref": "#/components/schemas/Page" } }
            } } }
          },
          "404": {
            "description": "Page (or site) not found. Returns the list of available paths on the site so the caller can recover.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "required": ["error"],
              "properties": {
                "error": { "type": "string" },
                "available": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "title": { "type": ["string", "null"] } } } }
              }
            } } }
          }
        }
      }
    }
  }
}
