{
  "openapi": "3.1.0",
  "info": {
    "title": "CiterLabs public API",
    "version": "1.0.0",
    "summary": "Free Gemini search and gap audit, and the AI crawler access checker.",
    "description": "Two free, unauthenticated endpoints run by CiterLabs.\n\n**Gemini audit** (`/api/gemini-audit`): runs 8 buyer-style prompts, 1 run each, on Google Gemini 2.5 Flash with Google Search grounding and reports whether a domain is named or cited, the searches Gemini ran, the most cited domains and the sites cited where the domain was absent. Gemini only: it does not query ChatGPT, Perplexity or Claude. Limits: 3 audits per IP per UTC day; a global daily cap (default 40 audits per UTC day, set by the operator); identical domain + category + competitors requests are cached for 24 hours and a cached answer uses no quota. Counters are best-effort (Workers KV has no atomic increment). POST requires an `Origin` header equal to `https://citerlabs.com` (browsers send it automatically on the page; other clients must set it). If Turnstile is enabled by the operator (`GET` reports a non-null `turnstileSiteKey`), POST also needs a `turnstileToken`, which only a browser can obtain.\n\n**Crawler access checker** (`/api/crawler-check`): fetches a public site's robots.txt and homepage with the user agent `CiterLabsCrawlerCheck/1.0 (+https://citerlabs.com/tools/crawler-access-check)`, signed with Web Bot Auth when the operator has configured the key, and reports per AI crawler what robots.txt allows. Limits: 20 checks per IP per UTC day, 1000 per day in total, 1 hour cache per domain.\n\nAn MCP server exposing the same features is at https://citerlabs.com/mcp. Contact: hi@citerlabs.com.",
    "contact": { "name": "CiterLabs", "email": "hi@citerlabs.com", "url": "https://citerlabs.com/contact" },
    "termsOfService": "https://citerlabs.com/privacy"
  },
  "servers": [{ "url": "https://citerlabs.com" }],
  "externalDocs": { "description": "How the audit works and its limits", "url": "https://citerlabs.com/free-gemini-audit" },
  "tags": [
    { "name": "gemini-audit", "description": "Free Gemini search and gap audit" },
    { "name": "crawler-check", "description": "AI crawler access checker" }
  ],
  "paths": {
    "/api/gemini-audit": {
      "get": {
        "tags": ["gemini-audit"],
        "operationId": "getGeminiAuditStatus",
        "summary": "Availability and limits (no spend)",
        "description": "Use this as a status check before calling POST. `available` is false when the operator has not configured credentials or storage; POST then returns 503.",
        "responses": {
          "200": {
            "description": "Status",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuditStatus" }, "example": { "available": true, "perIpLimit": 3, "turnstileSiteKey": null } } }
          }
        }
      },
      "post": {
        "tags": ["gemini-audit"],
        "operationId": "runGeminiAudit",
        "summary": "Run the 8-prompt Gemini audit for a domain",
        "description": "Takes up to about a minute (8 prompts, 4 in parallel, up to 45 s each with one retry). Quota is reserved before the run and given back when no prompt returns an answer. Do not submit an email address on someone's behalf: `email` requires that the person ticked consent on the page.",
        "parameters": [
          { "name": "Origin", "in": "header", "required": true, "schema": { "type": "string", "const": "https://citerlabs.com" }, "description": "Must equal the site origin, otherwise 403." }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AuditRequest" },
              "example": { "domain": "example.com", "category": "payroll software for small teams", "competitors": ["rival.com"] }
            }
          }
        },
        "responses": {
          "200": { "description": "Audit result (fresh or cached)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuditResponse" } } } },
          "400": {
            "description": "Invalid input. `error` is one of bad_request, invalid_domain, invalid_category, invalid_competitors, invalid_email, consent_required, turnstile.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "403": { "description": "Missing or foreign Origin header.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "413": { "description": "Body over 4096 bytes.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "415": { "description": "Content-Type is not application/json.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": {
            "description": "Limit reached. `error` is ip_limit (3 per IP per UTC day) or daily_cap (global cap). `quota.resetsAt` is the next UTC midnight.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "503": {
            "description": "`unavailable` (not configured or storage error) or `upstream` (Gemini returned no answers; the attempt was not counted).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/crawler-check": {
      "get": {
        "tags": ["crawler-check"],
        "operationId": "getCrawlerCheckStatus",
        "summary": "Availability, limits, signing status and crawler list",
        "responses": {
          "200": { "description": "Status", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CrawlerCheckStatus" } } } }
        }
      },
      "post": {
        "tags": ["crawler-check"],
        "operationId": "checkCrawlerAccess",
        "summary": "Check what AI crawlers may fetch on a site",
        "description": "Browsers must call it from https://citerlabs.com (a foreign Origin gets 403). Clients that send no Origin header are allowed.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CrawlerCheckRequest" }, "example": { "url": "example.com" } } }
        },
        "responses": {
          "200": { "description": "Report (fresh or cached)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CrawlerCheckResponse" } } } },
          "400": { "description": "Invalid JSON or `invalid_url` (not a public hostname).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "Cross-origin browser request.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "413": { "description": "Body over 2048 bytes.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "415": { "description": "Content-Type is not application/json.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "ip_limit (20 per IP per UTC day) or daily_cap (1000 per day).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "503": { "description": "Storage not available.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error", "message"],
        "properties": {
          "error": { "type": "string" },
          "message": { "type": "string" },
          "detail": { "type": "string", "description": "Non-secret diagnostic (upstream errors only)." },
          "quota": { "$ref": "#/components/schemas/Quota" }
        }
      },
      "Quota": {
        "type": "object",
        "required": ["limitPerDay", "remaining", "resetsAt"],
        "properties": {
          "limitPerDay": { "type": "integer" },
          "remaining": { "type": "integer" },
          "resetsAt": { "type": "string", "format": "date-time" }
        }
      },
      "AuditStatus": {
        "type": "object",
        "required": ["available", "perIpLimit", "turnstileSiteKey"],
        "properties": {
          "available": { "type": "boolean" },
          "perIpLimit": { "type": "integer", "examples": [3] },
          "turnstileSiteKey": { "type": ["string", "null"], "description": "Non-null when Turnstile is enforced on POST." }
        }
      },
      "AuditRequest": {
        "type": "object",
        "required": ["domain", "category"],
        "properties": {
          "domain": { "type": "string", "maxLength": 300, "description": "Public hostname; protocol, www. and path are stripped. IPs, ports, credentials, localhost and internal TLDs are rejected." },
          "category": { "type": "string", "minLength": 3, "maxLength": 80, "description": "Letters, digits, spaces and & , . ' / + ( ) -. Be specific." },
          "competitors": { "type": "array", "items": { "type": "string" }, "maxItems": 2, "description": "Up to 2 competitor domains (duplicates and the audited domain are dropped)." },
          "email": { "type": "string", "format": "email", "description": "Optional, only with consent: true from the person whose address it is." },
          "consent": { "type": "boolean" },
          "website": { "type": "string", "description": "Honeypot. Leave empty or omit; a non-empty value is refused." },
          "turnstileToken": { "type": "string", "description": "Only when Turnstile is enforced." }
        },
        "additionalProperties": true
      },
      "AuditResponse": {
        "type": "object",
        "required": ["ok", "cached", "result", "quota"],
        "properties": {
          "ok": { "const": true },
          "cached": { "type": "boolean" },
          "quota": { "$ref": "#/components/schemas/Quota" },
          "result": { "$ref": "#/components/schemas/AuditResult" }
        }
      },
      "AuditResult": {
        "type": "object",
        "required": ["engine", "date", "generatedAt", "domain", "category", "competitors", "runs", "summary", "prompts", "topDomains", "gaps"],
        "properties": {
          "engine": { "type": "object", "properties": { "name": { "type": "string" }, "model": { "type": "string" }, "grounding": { "type": "string" }, "platform": { "type": "string" }, "label": { "type": "string" } } },
          "date": { "type": "string", "format": "date" },
          "generatedAt": { "type": "string", "format": "date-time" },
          "domain": { "type": "string" },
          "category": { "type": "string" },
          "competitors": { "type": "array", "items": { "type": "string" } },
          "runs": {
            "type": "object",
            "properties": { "prompts": { "type": "integer" }, "runsPerPrompt": { "type": "integer" }, "answered": { "type": "integer" }, "failed": { "type": "integer" } }
          },
          "summary": {
            "type": "object",
            "description": "named = prompts whose answer mentions the domain or brand label; cited = prompts where a grounding source is the domain or a subdomain.",
            "properties": { "named": { "type": "integer" }, "cited": { "type": "integer" }, "answered": { "type": "integer" } }
          },
          "prompts": { "type": "array", "items": { "$ref": "#/components/schemas/AuditPrompt" } },
          "topDomains": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
          "gaps": {
            "type": "object",
            "description": "Domains cited on prompts where the audited domain was not cited, grouped by a simple rules-based classifier (some will be misgrouped).",
            "properties": {
              "answeredPrompts": { "type": "integer" },
              "gapPrompts": { "type": "integer" },
              "citedPrompts": { "type": "integer" },
              "groups": { "type": "array", "items": { "type": "object", "additionalProperties": true } }
            }
          }
        }
      },
      "AuditPrompt": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "kind": { "type": "string" },
          "text": { "type": "string" },
          "status": { "enum": ["ok", "failed"] },
          "error": { "type": "string" },
          "named": { "type": "boolean" },
          "cited": { "type": "boolean" },
          "competitorsNamed": { "type": "array", "items": { "type": "string" } },
          "competitorsCited": { "type": "array", "items": { "type": "string" } },
          "searchQueries": { "type": "array", "items": { "type": "string" } },
          "domains": { "type": "array", "items": { "type": "string" } },
          "sources": { "type": "array", "items": { "type": "object", "properties": { "domain": { "type": "string" }, "type": { "type": "string" } } } },
          "unresolvedSources": { "type": "integer" },
          "truncated": { "type": "boolean" },
          "answer": { "type": "string" }
        }
      },
      "CrawlerCheckStatus": {
        "type": "object",
        "properties": {
          "available": { "type": "boolean" },
          "perIpLimit": { "type": "integer" },
          "signed": { "type": "boolean", "description": "Whether outgoing requests are signed with Web Bot Auth on this deployment." },
          "userAgent": { "type": "string" },
          "crawlers": { "type": "array", "items": { "type": "string" } }
        }
      },
      "CrawlerCheckRequest": {
        "type": "object",
        "required": ["url"],
        "properties": { "url": { "type": "string", "description": "Domain or URL; only the hostname is used and only https://host/robots.txt and https://host/ are fetched." } }
      },
      "CrawlerCheckResponse": {
        "type": "object",
        "required": ["ok", "cached", "report", "quota"],
        "properties": {
          "ok": { "const": true },
          "cached": { "type": "boolean" },
          "quota": { "$ref": "#/components/schemas/Quota" },
          "report": {
            "type": "object",
            "properties": {
              "domain": { "type": "string" },
              "checkedAt": { "type": "string", "format": "date-time" },
              "checker": { "type": "object", "properties": { "userAgent": { "type": "string" }, "webBotAuth": { "type": "string" } } },
              "robots": { "type": "object", "additionalProperties": true, "description": "status, outcome (parsed | unavailable | unreachable), sitemaps, Content-Signal lines, or error." },
              "homepage": { "type": "object", "additionalProperties": true, "description": "HTTP status our checker received, final URL, redirects, or error." },
              "crawlers": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "token": { "type": "string" },
                    "vendor": { "type": "string" },
                    "purpose": { "type": "string" },
                    "docs": { "type": "string", "format": "uri" },
                    "group": { "enum": ["specific", "*", "none", "n/a"] },
                    "homepageAllowed": { "type": "boolean" },
                    "rule": { "type": ["string", "null"] },
                    "contentSignals": { "type": "array", "items": { "type": "string" } }
                  }
                }
              },
              "notes": { "type": "array", "items": { "type": "string" } }
            }
          }
        }
      }
    }
  }
}
