{"openapi":"3.0.0","paths":{"/v1/health":{"get":{"description":"Check API availability and connectivity. Returns the current server timestamp.","operationId":"PublicHealthController_getHealth_v1","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfHealthResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/HealthResponse"}},"required":["item"]}}}}},"summary":"Check API health","tags":["Health"]}},"/v1/projects":{"get":{"description":"List all projects belonging to your organization. Returns project metadata and the most recent completed monitoring timestamp for each project.","operationId":"PublicProjectsController_listProjects_v1","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ListResponseOfProjectListItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ProjectListItemResponse"}}},"required":["items"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List all projects","tags":["Projects"]}},"/v1/projects/{projectId}":{"get":{"description":"Get detailed information about a specific project, including per-dimension monitoring freshness and AI monitoring prompts.","operationId":"PublicProjectsController_getProject_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfProjectDetailResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/ProjectDetailResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get project details","tags":["Projects"]}},"/v1/projects/{projectId}/competitors":{"get":{"description":"List all competitors monitored within a project. Includes the project's own domain (marked with `isOwn: true`) and all added competitor domains.","operationId":"PublicCompetitorsController_listCompetitors_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ListResponseOfCompetitorListItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CompetitorListItemResponse"}}},"required":["items"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List project competitors","tags":["Competitors"]}},"/v1/projects/{projectId}/competitors/{competitorId}":{"get":{"description":"Get detailed information about a specific competitor within a project, including monitored page URLs (homepage and pricing).","operationId":"PublicCompetitorsController_getCompetitor_v1","parameters":[{"name":"competitorId","required":true,"in":"path","description":"Competitor ID","schema":{"example":"507f1f77bcf86cd799439012","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfCompetitorDetailResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/CompetitorDetailResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get competitor details","tags":["Competitors"]}},"/v1/projects/{projectId}/tech-trust":{"get":{"description":"Get the latest tech & trust profile data for all competitors in a project. Returns security headers, trust signals, technology stack, robots.txt analysis, AI access, and DNS infrastructure. Trust signals are a fixed set of 26 things we look for on a HOMEPAGE, in 5 categories — each category description names its exact membership and its denominator, so a count of 0 means none of THOSE signals was found and nothing more. Do not infer a signal a category does not list. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding. Two degradations are partial and worth distinguishing: shielded response headers null the security grade and score while the HTML-derived trust and tech counts stay real, and an unreadable robots.txt withholds the AI-access verdicts — under `aiAccess` the `assistantAccess` and `modelTrainingAccess` keys are OMITTED rather than nulled or emptied, because an empty array would claim we evaluated every assistant and none can reach the site. `aiAccess` itself is ABSENT on a check that carries no AI-access information — that is neither `could_not_measure` nor openness, so check the key exists before branching on it. Where it is present, check `aiAccess.measurement.status` before reading the verdicts: `measured_no_policy_found` is a real result — the site publishes no robots.txt, which under the standard allows every crawler — and `could_not_measure` is the only status that withholds verdicts. A project with no completed run for this dimension yet answers 404 `no_data_available` — not a missing project and not a failed measurement, just nothing measured here so far; the project itself is fine and `get_project` still describes it. Distinguish it from `project_not_found`, which means the id is wrong.","operationId":"PublicTechTrustController_getTechTrustDashboard_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTechTrustDashboardResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TechTrustDashboardResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get latest tech & trust data","tags":["Tech & Trust Profile"]}},"/v1/projects/{projectId}/tech-trust/history":{"get":{"description":"Get paginated history of tech & trust monitoring runs for a project. Each entry includes run timing and summary statistics. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding. The two gap figures are null when there was no comparison to make — either side unmeasured, or no competitor to compare against — so a null gap is not a tie.","operationId":"PublicTechTrustController_getTechTrustHistory_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfTechTrustHistoryItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TechTrustHistoryItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"}},"required":["items","pagination"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get tech & trust run history","tags":["Tech & Trust Profile"]}},"/v1/projects/{projectId}/tech-trust/history/{runId}":{"get":{"description":"Get full tech & trust data for a specific historical monitoring run, including per-competitor security headers, trust signals, technology stack, robots.txt, and DNS infrastructure. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding. A competitor whose robots.txt reads `exists: false` with NO availability marker genuinely publishes none, which allows all crawlers — a real finding, distinct from a null. A run that finished but produced no summary answers 404 `run_not_summarized`, which is a different fact from `run_not_found`: the run exists, it just has nothing to report. It used to answer 200 with an all-zero summary invented from the missing one.","operationId":"PublicTechTrustController_getTechTrustRunDetail_v1","parameters":[{"name":"runId","required":true,"in":"path","description":"Run ID","schema":{"example":"507f1f77bcf86cd799439012","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTechTrustRunDetailResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TechTrustRunDetailResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get tech & trust data for a specific run","tags":["Tech & Trust Profile"]}},"/v1/projects/{projectId}/content":{"get":{"description":"Get the latest content intelligence data for all competitors in a project. Returns URL counts, content categorization, strategic URL analysis, and competitive gap assessment. Use this to understand the content landscape across competitors. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding. The four gap lists carry a distinction worth respecting: a null list means no comparison ran, while an EMPTY list means we compared and found none — reading the first as the second reports a verdict about a comparison that never happened. A project with no completed run for this dimension yet answers 404 `no_data_available` — not a missing project and not a failed measurement, just nothing measured here so far; the project itself is fine and `get_project` still describes it. Distinguish it from `project_not_found`, which means the id is wrong.","operationId":"PublicContentController_getContentDashboard_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfContentDashboardResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/ContentDashboardResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get latest content intelligence data","tags":["Content Intelligence"]}},"/v1/projects/{projectId}/content/history":{"get":{"description":"Get paginated history of content intelligence monitoring runs for a project. Each entry includes run timing and summary statistics with gap analysis. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding.","operationId":"PublicContentController_getContentHistory_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfContentHistoryItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ContentHistoryItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"}},"required":["items","pagination"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get content intelligence run history","tags":["Content Intelligence"]}},"/v1/projects/{projectId}/content/history/{runId}":{"get":{"description":"Get full content intelligence data for a specific historical monitoring run, including per-competitor URL counts, content categorization, and strategic URL metrics. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding. A run that finished but produced no summary answers 404 `run_not_summarized`, which is a different fact from `run_not_found`: the run exists, it just has nothing to report. It used to answer 200 with an all-zero summary invented from the missing one.","operationId":"PublicContentController_getContentRunDetail_v1","parameters":[{"name":"runId","required":true,"in":"path","description":"Run ID","schema":{"example":"507f1f77bcf86cd799439012","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfContentRunDetailResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/ContentRunDetailResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get content intelligence data for a specific run","tags":["Content Intelligence"]}},"/v1/projects/{projectId}/content/changelog":{"get":{"description":"Get paginated changelog of detected URL changes across competitor sitemaps. Each item shows numeric counts per category plus up to 3 sample URLs per category by default — safe for any token budget. Supports filtering by `competitorId` and `category` query parameters. Pass `allUrlsPerCategory=true` for the full URL list per category (warning: high-activity competitors can produce large responses; combine with filters to scope, and check the `truncated` flag in the response — when true, the byte cap fired and items/URLs were trimmed).","operationId":"PublicContentController_getContentChangelog_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"competitorId","required":false,"in":"query","description":"Filter by competitor ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}},{"name":"category","required":false,"in":"query","description":"Filter by content category (blog, docs, tools, landing, caseStudies, comparison, integrations, changelog, webinars, legal, programmatic, other). Changelog rows carry the same category the content dashboard reports for the same URL, decided over the competitor's whole sitemap rather than over the changed URLs alone — so a page added into a templated catalog filters under `programmatic`, not `other`.","schema":{"$ref":"#/components/schemas/ContentCategory"}},{"name":"allUrlsPerCategory","required":false,"in":"query","description":"By default each item returns up to 3 sample URLs per category. Set true to return the full URL list per category. High-activity competitors can produce large responses — combine with `category` and `competitorId` filters to scope. Subject to an internal byte cap; check `truncated` in the response.","schema":{"example":false,"type":"boolean"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfContentChangelogItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ContentChangelogItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"},"truncated":{"type":"boolean","description":"True when the verbose-mode byte cap fired and items/URLs were trimmed to fit the response budget. Refine your query with category/competitorId filters when seen. Always false in default mode.","example":false}},"required":["items","pagination","truncated"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get content changelog","tags":["Content Intelligence"]}},"/v1/projects/{projectId}/positioning":{"get":{"description":"Get the latest positioning data for all competitors in a project. Returns homepage messaging, value propositions, CTAs, target audience, and competitive messaging analysis. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding. For the messaging strings the distinction is sharp: a measured EMPTY STRING means we read the page and there is genuinely no call to action, which is a finding; a null means we never read it. A project with no completed run for this dimension yet answers 404 `no_data_available` — not a missing project and not a failed measurement, just nothing measured here so far; the project itself is fine and `get_project` still describes it. Distinguish it from `project_not_found`, which means the id is wrong.","operationId":"PublicPositioningController_getPositioningDashboard_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfPositioningDashboardResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/PositioningDashboardResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get latest positioning data","tags":["Positioning"]}},"/v1/projects/{projectId}/positioning/history":{"get":{"description":"Get paginated history of positioning monitoring runs for a project. Each entry includes run timing and summary statistics with messaging gap analysis. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding.","operationId":"PublicPositioningController_getPositioningHistory_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfPositioningHistoryItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/PositioningHistoryItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"}},"required":["items","pagination"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get positioning run history","tags":["Positioning"]}},"/v1/projects/{projectId}/positioning/history/{runId}":{"get":{"description":"Get full positioning data for a specific historical monitoring run, including per-competitor homepage messaging, value propositions, CTAs, and audience data. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding. A run that finished but produced no summary answers 404 `run_not_summarized`, which is a different fact from `run_not_found`: the run exists, it just has nothing to report. It used to answer 200 with an all-zero summary invented from the missing one.","operationId":"PublicPositioningController_getPositioningRunDetail_v1","parameters":[{"name":"runId","required":true,"in":"path","description":"Run ID","schema":{"example":"507f1f77bcf86cd799439012","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfPositioningRunDetailResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/PositioningRunDetailResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get positioning data for a specific run","tags":["Positioning"]}},"/v1/projects/{projectId}/pricing":{"get":{"description":"Get the latest pricing intelligence data for all competitors in a project. Returns structured pricing plans, free tier availability, billing options, enterprise pricing, promotions, and competitive gap analysis. Every tracked competitor appears — rows without pricing data carry a `pricingDataAvailable` reason (no pricing page found, their page didn't respond, or a temporary problem on our side) instead of being omitted; branch on it before quoting pricing facts. A pricing page is the single most likely thing to be missing, so this is the dimension where the null contract matters most: when your own pricing couldn't be analyzed, every metric on `summary.customer` is null and `pricingAnalysisAvailable` says why. `hasFreePlan: null` means we did not check, NOT that there is no free plan — a measured `false` is reported as `false` and is a real finding. The three gap flags do NOT share one null condition, so read each field's own description rather than gating all three on the same test: all three are null when your side was unmeasured or no competitor was measured, and `hasPriceGap` is null on two further conditions the other two don't carry — fewer than three comparable competitor prices, or your own price not being comparable to the market. Market figures (`marketAvgPrice`, `pricePositionPercent`) are null below that three-price threshold, which is 'not enough market to average', never zero. Comparable means fixed monthly amounts sharing one currency AND one licensed unit: a per-seat price is never averaged against a flat one, `marketPricingUnit` says which group was chosen, and every market figure must be quoted with it. A project with no completed run for this dimension yet answers 404 `no_data_available` — not a missing project and not a failed measurement, just nothing measured here so far; the project itself is fine and `get_project` still describes it. Distinguish it from `project_not_found`, which means the id is wrong.","operationId":"PublicPricingController_getPricingDashboard_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfPricingDashboardResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/PricingDashboardResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get latest pricing intelligence data","tags":["Pricing Intelligence"]}},"/v1/projects/{projectId}/pricing/history":{"get":{"description":"Get paginated history of pricing intelligence monitoring runs for a project. Each entry includes run timing and summary statistics with pricing gap analysis. Fields we could not measure are null, with a sibling availability object giving the reason; never read a null as zero, false or absent, and note that a measured 0 / false / empty list is reported as itself and is a real finding.","operationId":"PublicPricingController_getPricingHistory_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfPricingHistoryItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/PricingHistoryItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"}},"required":["items","pagination"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get pricing run history","tags":["Pricing Intelligence"]}},"/v1/projects/{projectId}/pricing/history/{runId}":{"get":{"description":"Get full pricing intelligence data for a specific historical monitoring run, including per-competitor pricing plans, billing options, free tiers, and enterprise pricing. Competitors the run could not measure appear with a `pricingDataAvailable` reason instead of being omitted. Fields we could not measure are null; never read a null as zero, false or absent, and note that a measured 0 / false is reported as itself and is a real finding. A run that finished but produced no summary answers 404 `run_not_summarized`, which is a different fact from `run_not_found`: the run exists, it just has nothing to report. It used to answer 200 with an all-zero summary invented from the missing one.","operationId":"PublicPricingController_getPricingRunDetail_v1","parameters":[{"name":"runId","required":true,"in":"path","description":"Run ID","schema":{"example":"507f1f77bcf86cd799439012","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfPricingRunDetailResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/PricingRunDetailResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get pricing data for a specific run","tags":["Pricing Intelligence"]}},"/v1/projects/{projectId}/ai-visibility":{"get":{"description":"Get the latest AI visibility data for a project. Returns AI Visibility Scores, mention rates, and per-model breakdowns for all competitors found. Use this to understand how AI models perceive and recommend your brand vs competitors. The score counts only the top 5 positions in an answer, evenly spaced — first place the most, the last scoring position the least — and nothing below them. It is a reading of WHERE a brand lands when it is named, never of who is ahead: a standing claim — \"you lead\", \"you trail\", \"the leader is X\" — rests on how often each brand is named (presence on the market map, or mentionRate within one check) and never on this score, which can favour a brand named half as often. A score of 0 for a brand the answers did name means it sat only in the tail of AI recommendations — below the top 5, or too seldom inside it for the average to register. Read mentionRate beside a 0 score: a non-zero rate means the brand was named, and a 0 rate means no counted answer named it. Each check asks every prompt in the project against every AI model it was dispatched to — 5 today, but a check keeps the model set it ran with, so read answerCoverage.queriesSent and providerStatus for that check's own count rather than assuming today's. Rates divide by the answers that came back, not the queries sent, and the queries-sent figure is not returned. Checks published under the full-coverage gate were read for every query they asked — a usable answer came back, or the model was read and had none to show (`noAnswerShown`, the third query state beside `answers` and `unansweredQueries`: read, nothing shown, excluded from every count, not a failure); checks published before that gate stay published and can have been scored over fewer answers, and the response does not say which kind a check is — so describe a rate as a share of the answers counted, never as a share of every query asked. When the most recent check was abandoned as incomplete, `latestCheckDataAvailable` is present and everything else describes an earlier check. Set `includeAnswers=true` to also get what the models said on that check — every prompt sent and every brand each model named in rank order, with its stated reasoning — narrowed by `brand=<domain>`, `provider=` or `promptIndex=`. Roughly 25k tokens unfiltered on a three-engine check and 46k on a five-engine one, against 2k with `brand=` — or 9k once Google AI Overviews is in the ask, whose overview text and cited pages that filter keeps by design; use `summary.totalEntries` to size it first. `brand=` keeps every answer and empties the ones that did not name that domain, so you see where they win and where they are invisible. No filter changes a number under `summary`. The prose returned is the model's wording about the brands it named, not CompetLab's assessment of them. For 'which prompt am I losing on', read `summary.customer.perPrompt` first — it is on the plain response and costs nothing. A project with no scored check yet answers 404 `no_data_available` — not a missing project and not a failed measurement, just nothing measured here so far; the project itself is fine and `get_project` still describes it. Distinguish it from `project_not_found`, which means the id is wrong.","operationId":"PublicAiVisibilityController_getAiVisibilityDashboard_v1","parameters":[{"name":"includeAnswers","required":false,"in":"query","description":"Set true to include the models' raw answers — every prompt sent and every brand each model named, with its stated reasoning. Off by default because the block is large: roughly 25k tokens unfiltered on a three-engine check and 46k on a five-engine one, against roughly 2k with `brand=`, or 9k once Google AI Overviews is in the ask. Read `summary.totalEntries` to size it first (about 375 tokens per entry, plus the overview text and cited pages on each Google AI Overviews answer, which the brand filter keeps and which `totalEntries` does not predict). The prose it returns is the model's wording about the brands it named, not CompetLab's assessment.","schema":{"default":false,"example":false,"type":"boolean"}},{"name":"provider","required":false,"in":"query","description":"Return only this model's answers. Requires `includeAnswers=true`. Does not change any number under `summary`, and does not narrow `providerStatus`.","schema":{"$ref":"#/components/schemas/AiProvider"}},{"name":"brand","required":false,"in":"query","description":"Return only the entries for this domain, across every answer. Requires `includeAnswers=true`. Matches `brands[].domain`, case-insensitively; brand NAMES are the model's own wording and vary between answers, so they are never matched. EVERY answer is still returned — the ones that did not name this domain come back with an empty `brands`, because 'this model answered and did not name them' is a finding, not an absence of data. A query that produced no answer at all is in `unansweredQueries` instead and asserts nothing about anyone. Ranks are unaffected: an entry keeps the position it held in the full answer. This is the cheapest way to ask where a competitor wins and where they are invisible — roughly 2k tokens, plus the overview text and cited pages on each Google AI Overviews answer, which the brand filter keeps.","schema":{"example":"competitor.com","type":"string"}},{"name":"promptIndex","required":false,"in":"query","description":"Return only the answers for this prompt, across every model. Requires `includeAnswers=true`. Zero-based, matching the per-prompt index used elsewhere in this dimension.","schema":{"example":0,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfAiVisibilityDashboardResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/AiVisibilityDashboardResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get latest AI visibility data","tags":["AI Visibility"]}},"/v1/projects/{projectId}/ai-visibility/history":{"get":{"description":"Get paginated history of AI visibility checks for a project. Each entry includes check timing and summary statistics with customer metrics, top competitor, and competitor rankings. The score counts only the top 5 positions in an answer, evenly spaced — first place the most, the last scoring position the least — and nothing below them. It is a reading of WHERE a brand lands when it is named, never of who is ahead: a standing claim — \"you lead\", \"you trail\", \"the leader is X\" — rests on how often each brand is named (presence on the market map, or mentionRate within one check) and never on this score, which can favour a brand named half as often. A score of 0 for a brand the answers did name means it sat only in the tail of AI recommendations — below the top 5, or too seldom inside it for the average to register. Read mentionRate beside a 0 score: a non-zero rate means the brand was named, and a 0 rate means no counted answer named it. Only scored checks are listed — under the full-coverage gate a cycle with a query it could not read is never scored and does not appear here; a query the model was read for and had no answer to show (`noAnswerShown` on that check's answers) does not count against it. Checks published before that gate remain listed and can have been scored over fewer answers than they asked queries; the queries-sent figure is not returned, so a listed check cannot be shown to be fully covered. Each entry carries `summary.totalEntries`, which is the size preview for that check's raw answers via the check-detail route. This page is subject to a response size cap: when `truncated` is true, whole entries were dropped from the end of `items` and `pagination.hasMore` does NOT account for them — lower `limit` to see the rest rather than paging forward, which would skip them.","operationId":"PublicAiVisibilityController_getAiVisibilityHistory_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfAiVisibilityHistoryItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AiVisibilityHistoryItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"},"truncated":{"type":"boolean","description":"True when the page exceeded the response size cap and whole entries were dropped from the end of `items`. `pagination.total` still reports the true number of checks, so a withheld entry is distinguishable from one that does not exist — but `pagination.hasMore` does NOT account for dropped rows, so paging forward on `hasMore` alone would skip them silently. Lower `limit` and re-request this page instead.","example":false}},"required":["items","pagination","truncated"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get AI visibility check history","tags":["AI Visibility"]}},"/v1/projects/{projectId}/ai-visibility/history/{checkId}":{"get":{"description":"Get full AI visibility data for a specific historical check: per-competitor rankings across every AI model we query, with mention rates and AI Visibility Scores, under `summary`. The score counts only the top 5 positions in an answer, evenly spaced — first place the most, the last scoring position the least — and nothing below them. It is a reading of WHERE a brand lands when it is named, never of who is ahead: a standing claim — \"you lead\", \"you trail\", \"the leader is X\" — rests on how often each brand is named (presence on the market map, or mentionRate within one check) and never on this score, which can favour a brand named half as often. A score of 0 for a brand the answers did name means it sat only in the tail of AI recommendations — below the top 5, or too seldom inside it for the average to register. These rows name other companies, so reporting a 0 as 'never named' is a false claim about a third party published under CompetLab's name. Read mentionRate beside a 0 score: a non-zero rate means the brand was named, and a 0 rate means no counted answer named it. Rates here divide by the answers that came back, not the queries sent, and the queries-sent figure is not on `summary` — describe a rate as a share of the answers counted, never as a share of every query asked. Set `includeAnswers=true` to also get what the models actually said: every prompt sent, and every brand each model named in rank order with its stated reasoning, plus `providerStatus` and (where the check recorded its ask) `answerCoverage`. That block is large — roughly 25k tokens unfiltered on a three-engine check and 46k on a five-engine one, against roughly 2k with `brand=`, or 9k once Google AI Overviews is in the ask. Check `summary.totalEntries` to size it first (about 375 tokens per entry, plus the overview text and cited pages on each Google AI Overviews answer, which the brand filter keeps and which `totalEntries` does not predict), and prefer `brand=<domain>` (one competitor across every answer) or `provider=` (one model) over fetching everything; `promptIndex=` narrows to a single prompt. `provider` and `promptIndex` narrow the answers array; `brand` does not — it reduces the `brands` list inside each answer, so every answer still comes back and the ones that did not name that domain arrive with an empty `brands`, which is a finding rather than an absence. No filter changes any number under `summary`: those are stored, computed over the whole check, and never recomputed for a filtered view. Ranks are stable under filtering. Queries that produced no usable answer are listed separately in `unansweredQueries` rather than appearing as answers naming nobody, and queries the model was read for and had no answer to show — today, prompts Google showed no AI Overview for — in `noAnswerShown`, excluded from every count and not a failure; those are three different facts, and an empty `brands` under a `brand` filter is none of them. `summary.customer.perPrompt` breaks the customer's result down per prompt at no extra cost — use it before reaching for `includeAnswers`.","operationId":"PublicAiVisibilityController_getAiVisibilityCheckDetail_v1","parameters":[{"name":"checkId","required":true,"in":"path","description":"Check ID","schema":{"example":"507f1f77bcf86cd799439012","type":"string"}},{"name":"includeAnswers","required":false,"in":"query","description":"Set true to include the models' raw answers — every prompt sent and every brand each model named, with its stated reasoning. Off by default because the block is large: roughly 25k tokens unfiltered on a three-engine check and 46k on a five-engine one, against roughly 2k with `brand=`, or 9k once Google AI Overviews is in the ask. Read `summary.totalEntries` to size it first (about 375 tokens per entry, plus the overview text and cited pages on each Google AI Overviews answer, which the brand filter keeps and which `totalEntries` does not predict). The prose it returns is the model's wording about the brands it named, not CompetLab's assessment.","schema":{"default":false,"example":false,"type":"boolean"}},{"name":"provider","required":false,"in":"query","description":"Return only this model's answers. Requires `includeAnswers=true`. Does not change any number under `summary`, and does not narrow `providerStatus`.","schema":{"$ref":"#/components/schemas/AiProvider"}},{"name":"brand","required":false,"in":"query","description":"Return only the entries for this domain, across every answer. Requires `includeAnswers=true`. Matches `brands[].domain`, case-insensitively; brand NAMES are the model's own wording and vary between answers, so they are never matched. EVERY answer is still returned — the ones that did not name this domain come back with an empty `brands`, because 'this model answered and did not name them' is a finding, not an absence of data. A query that produced no answer at all is in `unansweredQueries` instead and asserts nothing about anyone. Ranks are unaffected: an entry keeps the position it held in the full answer. This is the cheapest way to ask where a competitor wins and where they are invisible — roughly 2k tokens, plus the overview text and cited pages on each Google AI Overviews answer, which the brand filter keeps.","schema":{"example":"competitor.com","type":"string"}},{"name":"promptIndex","required":false,"in":"query","description":"Return only the answers for this prompt, across every model. Requires `includeAnswers=true`. Zero-based, matching the per-prompt index used elsewhere in this dimension.","schema":{"example":0,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfAiVisibilityCheckDetailResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/AiVisibilityCheckDetailResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get AI visibility data for a specific check","tags":["AI Visibility"]}},"/v1/projects/{projectId}/ai-visibility/trend":{"get":{"description":"How the market the AI models draw has moved over a window: one row per company with how often it was recommended at the start of the window and now — a share of the answers pooled in each check's window, with a 95% range and a zone — plus its rank by that share and its AI Visibility Score at both ends, the difference between the ends in each unit, and the models backing it. The project's own company and its tracked competitors are always rows; the rest are the most recommended companies, up to ten rows in all, ordered by how often each is recommended on the latest map (ties are ties, so never break one). Two companies whose ranges overlap are not in a settled order whatever their shares say. The score counts only the top 5 positions in an answer, evenly spaced — first place the most, the last scoring position the least — and nothing below them. It is a reading of WHERE a brand lands when it is named, never of who is ahead: a standing claim — \"you lead\", \"you trail\", \"the leader is X\" — rests on how often each brand is named (presence on the market map, or mentionRate within one check) and never on this score, which can favour a brand named half as often. A score of 0 for a brand the answers did name means it sat only in the tail of AI recommendations — below the top 5, or too seldom inside it for the average to register. `events` carries what happened on the axis: the customer's own standing changing zone and holding (the alerts the customer received — a standing is announced only once it has held for two checks), the cycles that produced no reading, and when the prompts were last edited (readings before that date answer different questions). `provider` reads one model's own slice of every map; rank and score are then absent, since they exist only across every model. `detail=series` adds each company's share check by check, downsampled to at most twelve points; omit it unless the shape between the ends matters. The window reads at most the newest 200 published checks — use dateFrom/dateTo for longer histories. Every figure here is a stored map's own: a company a map does not carry is a measured zero of that map's answers, and a model with no usable answer in a window is null, never zero.","operationId":"PublicAiVisibilityController_getAiVisibilityTrend_v1","parameters":[{"name":"dateFrom","required":false,"in":"query","description":"Start of the window (ISO-8601). Omit for the whole history: the newest 200 published checks, and the newest 200 cycles that produced no reading — on a longer history set both dates so the two cover one span.","schema":{"example":"2026-01-01","type":"string"}},{"name":"dateTo","required":false,"in":"query","description":"End of the window (ISO-8601).","schema":{"example":"2026-03-15","type":"string"}},{"name":"provider","required":false,"in":"query","description":"Read one AI model's own slice of every map instead of the pooled map. Omit for every model at once. Under one model `rank` and `score` are null on every reading and `enginesBacking` is left off the rows — they exist only across every model; never read that as 'no model named them'.","schema":{"$ref":"#/components/schemas/AiProvider"}},{"name":"detail","required":false,"in":"query","description":"`series` adds each company's share check by check, downsampled to at most 12 points spread evenly over the window. Omit it unless the shape between the two ends matters: the rows already carry the reading now, the reading at the start and the difference.","schema":{"example":"series","type":"string","enum":["series"]}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfAiVisibilityTrendResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/AiVisibilityTrendResponse"}},"required":["item"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get how the market the AI models draw has moved","tags":["AI Visibility"]}},"/v1/projects/{projectId}/ai-sources":{"get":{"description":"Get the latest AI Sources data for a project: for each engine we ask — Perplexity and Google AI Overviews — which companies it named in answer to the project's 8 buying questions, which pages it RETRIEVED to answer, and which of the pages more than one engine retrieved name the customer's competitors and not the customer. Every number is read off the stored summary of the latest published check, the same document the app shows. Two sentences this data cannot support, and no field here claims: a CITATION count — an engine hands back the pages it retrieved while answering and does not say which it leaned on — and a POOLED page number — the engines read different pages, so page counts are per engine and are never added together. The one cross-engine object is the core, the hosts at least 2 engines retrieved (`summary.overlap`, `summary.coreHosts`); answers may pool as a vote on the leaderboard (`summary.brands`), and every such row says which engines back it. Counts are counts, never rates: report figures as `n of N answers` and never as a percentage or a share — the question set is small by design, and a share computed from it is false precision. Every count names its universe on the same object. An engine the check did not ask is ABSENT from every per-engine record, and absent means not measured, never zero; an engine present with `engineDataAvailable` produced nothing usable and carries no count. The question matrix has three cell states, never two: `answered`, `no_answer_shown` (the engine was read and showed nothing — measured, not an answer, not a failure) and `not_measured` (we could not read it — not a zero). A page we could not read is listed under its host with its reason and is never counted as a page the customer is absent from; only a core host whose `status` is `missing` supports 'get onto this page'. `actionHint.text` on each core host and the sentences under `summary.limits.sentences` are payload: render them verbatim. Set `includeAnswers=true` to also get the engines' raw answers on that check — the answer text, the companies read out of it in order of first mention, and the pages each engine retrieved with the passage it handed back — narrowed by `engine=` or `promptIndex=`. The block is large; the prose in it is the engine's, not CompetLab's. No filter changes a number under `summary`. When the most recent check was abandoned because no engine produced a usable answer, `latestCheckDataAvailable` is present and everything else describes an earlier check. A project with no published check yet answers 404 `no_data_available` — not a missing project and not a failed measurement, just nothing measured here so far; the project itself is fine and `get_project` still describes it. Distinguish it from `project_not_found`, which means the id is wrong.","operationId":"PublicAiSourcesController_getAiSourcesDashboard_v1","parameters":[{"name":"includeAnswers","required":false,"in":"query","description":"Set true to include what the engines actually said — every question sent, the answer text, the companies read out of it in order of first mention, and the pages the engine RETRIEVED to write it, with the passage it handed back for each. Off by default because the block is large: up to 8 answers per engine, each carrying the engine's full retrieved page list, which for a searching engine can run to dozens of pages with a passage each. Narrow with `engine=` or `promptIndex=` rather than fetching everything. The answer text is the engine's wording, not CompetLab's assessment; the pages are retrieved, never cited.","schema":{"default":false,"example":false,"type":"boolean"}},{"name":"engine","required":false,"in":"query","description":"Return only this engine's answers. Requires `includeAnswers=true`. Narrows `answers`, `unansweredQueries` and `noAnswerShown`; changes nothing under `summary` and does not narrow `engineStatus`.","schema":{"$ref":"#/components/schemas/AiSourcesEngine"}},{"name":"promptIndex","required":false,"in":"query","description":"Return only the answers for this question, across every engine. Requires `includeAnswers=true`. Zero-based: the question's position in the check's question list, matching `promptIndex` on each answer.","schema":{"example":0,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfAiSourcesDashboardResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/AiSourcesDashboardResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get latest AI sources data","tags":["AI Sources"]}},"/v1/projects/{projectId}/ai-sources/history":{"get":{"description":"Get paginated history of AI Sources checks for a project. Each row carries, per engine, the four measured figures for that check — answers received, answers naming the customer, pages read, and independent pages naming the customer — and the funnel from core hosts to hosts the customer is genuinely missing from. Nothing is summed across engines; an engine absent from a row was not asked on that check or produced nothing usable on it, and absent means not measured, never zero. Counts are counts, never rates: report figures as `n of N answers` and never as a percentage or a share — the question set is small by design, and a share computed from it is false precision. Only published checks are listed: a check abandoned because no engine produced a usable answer never publishes and does not appear here. Uses `checkId`, not `runId` — the unit of this dimension is a check, one cycle of every question against every engine. The full summary for a row is on the check-detail route. This page is subject to a response size cap: when `truncated` is true, whole entries were dropped from the end of `items` and `pagination.hasMore` does NOT account for them — lower `limit` to see the rest rather than paging forward, which would skip them.","operationId":"PublicAiSourcesController_getAiSourcesHistory_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfAiSourcesHistoryItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AiSourcesHistoryItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"},"truncated":{"type":"boolean","description":"True when the page exceeded the response size cap and whole entries were dropped from the end of `items`. `pagination.total` still reports the true number of checks, so a withheld entry is distinguishable from one that does not exist — but `pagination.hasMore` does NOT account for dropped rows, so paging forward on `hasMore` alone would skip them silently. Lower `limit` and re-request this page instead.","example":false}},"required":["items","pagination","truncated"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get AI sources check history","tags":["AI Sources"]}},"/v1/projects/{projectId}/ai-sources/history/{checkId}":{"get":{"description":"Get full AI Sources data for a specific historical check: the summary stored on that check under `summary`, in exactly the dashboard's shape and read the same way — per engine, never pooled; retrieved, never cited; only a `missing` core host is a page to get onto; `actionHint.text` and `summary.limits.sentences` are rendered verbatim. The window it pools over (up to 5 published checks, this one first) is on `summary.window`. Set `includeAnswers=true` to also get what the engines actually said: every question sent, the answer text, the companies read out of it in order of first mention, and the pages the engine retrieved with the passage it handed back for each — plus `engineStatus`, one entry per engine the check asked. That block is large; narrow with `engine=` (one engine) or `promptIndex=` (one question). No filter changes any number under `summary`; ranks are stable under filtering. Counts are counts, never rates: report figures as `n of N answers` and never as a percentage or a share — the question set is small by design, and a share computed from it is false precision. Questions we could not read are listed separately in `unansweredQueries`, and questions an engine was read for and showed no answer to in `noAnswerShown` — three different facts, and none of them is 'not named'. A malformed id answers 400 `invalid_check_id`; an id that does not belong to this project answers 404 `check_not_found`; a check that exists but has no published summary — still running, or abandoned (no engine produced a usable answer, or the page stage could not be closed) — answers 404 `run_not_summarized`, which is a different fact from `check_not_found`: the check exists, it has nothing to report.","operationId":"PublicAiSourcesController_getAiSourcesCheckDetail_v1","parameters":[{"name":"checkId","required":true,"in":"path","description":"Check ID","schema":{"example":"507f1f77bcf86cd799439012","type":"string"}},{"name":"includeAnswers","required":false,"in":"query","description":"Set true to include what the engines actually said — every question sent, the answer text, the companies read out of it in order of first mention, and the pages the engine RETRIEVED to write it, with the passage it handed back for each. Off by default because the block is large: up to 8 answers per engine, each carrying the engine's full retrieved page list, which for a searching engine can run to dozens of pages with a passage each. Narrow with `engine=` or `promptIndex=` rather than fetching everything. The answer text is the engine's wording, not CompetLab's assessment; the pages are retrieved, never cited.","schema":{"default":false,"example":false,"type":"boolean"}},{"name":"engine","required":false,"in":"query","description":"Return only this engine's answers. Requires `includeAnswers=true`. Narrows `answers`, `unansweredQueries` and `noAnswerShown`; changes nothing under `summary` and does not narrow `engineStatus`.","schema":{"$ref":"#/components/schemas/AiSourcesEngine"}},{"name":"promptIndex","required":false,"in":"query","description":"Return only the answers for this question, across every engine. Requires `includeAnswers=true`. Zero-based: the question's position in the check's question list, matching `promptIndex` on each answer.","schema":{"example":0,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfAiSourcesCheckDetailResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/AiSourcesCheckDetailResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get AI sources data for a specific check","tags":["AI Sources"]}},"/v1/projects/{projectId}/alerts":{"get":{"description":"Get paginated competitive intelligence alerts for a project. Returns alerts generated when monitoring detects meaningful changes in competitor activity — such as pricing restructures, content strategy shifts, or technology stack changes. Supports filtering by monitoring dimension (tech-trust, content, positioning, pricing, ai-visibility, ai-sources), severity level (critical, high, medium, info), and specific competitor. Alerts are sorted by creation date (newest first).","operationId":"PublicAlertsController_listAlerts_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"dimension","required":false,"in":"query","description":"Filter by monitoring dimension (tech-trust, content, positioning, pricing, ai-visibility, ai-sources)","schema":{"example":"pricing","type":"string","enum":["tech-trust","content","positioning","pricing","ai-visibility","ai-sources"]}},{"name":"severity","required":false,"in":"query","description":"Filter by alert severity (critical, high, medium, info)","schema":{"example":"critical","type":"string","enum":["critical","high","medium","info"]}},{"name":"competitorId","required":false,"in":"query","description":"Filter by competitor ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfAlertListItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AlertListItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"}},"required":["items","pagination"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List project alerts","tags":["Alerts"]}},"/v1/projects/{projectId}/schedules":{"get":{"description":"Get monitoring schedules for all dimensions in a project. Returns the current configuration (enabled/disabled, interval) and timing (next run, last run) for each monitoring dimension. Use this to monitor scheduling health, detect stalled dimensions, or build alerting around missed runs.","operationId":"PublicSchedulesController_listSchedules_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ListResponseOfScheduleItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ScheduleItemResponse"}}},"required":["items"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List monitoring schedules","tags":["Schedules"]}},"/v1/projects/{projectId}/strategic-briefing":{"get":{"description":"Get the current state of this project's strategic briefing — the synthesized, as-of competitive read (what changed, what it means, what to do). This is the ANALYZED briefing; for the raw monitoring data use the per-dimension dashboards (e.g. `/pricing`), and for the monitored-competitor roster use `/competitors`.\n\n**This returns the LATEST run, whatever state it is in.** `meta.status` is that run's raw lifecycle status and is the field to branch on:\n\n- `done` — the run finished. `item`, `coverage` and `contains` are populated; this is the readable edition.\n- `running` — a briefing is being generated right now. `item` is null; `meta.progress` carries the step it is on and when it started. A typical run finishes in about two hours — budget rather than busy-wait. Treat it as running until this status changes, and never infer that a run is late or failed from how long it has taken.\n- `failed` — the most recent attempt ended without producing an edition. `item` is null. A failed run does not resume the ~30-day cycle, so surface it rather than waiting it out.\n- `null` — this project has never had a briefing run at all. **This is the only value that means the project genuinely has nothing.**\n\n**When `meta.status` is not `done`, THIS RUN has no content — which says nothing about whether the project has a briefing.** A previously published edition is usually still readable: call `GET /v1/projects/{projectId}/strategic-briefing/history` to list the editions and `…/history/{runId}` to read one. **Never report that no briefing is available on the strength of a null `item` — check history first.**\n\nA missing or non-accessible project returns 404 `project_not_found`; the briefing lifecycle itself never 404s.\n\n**Sections — lean by default.** `sections` selects what to return and defaults to `[\"hub\"]` — the executive digest AND the navigation map. Each hub `diagnosis` row carries a `deepDive` pointer naming the dimension to read next; prefix it with `deep-` to form the section value (a pointer of `ai-visibility` → request `sections=deep-ai-visibility`). Valid sections: `hub`, `actions`, `competitors`, the fourteen `deep-<dimension>` deep-dives, or `all` for the full document. Charts are off by default (`includeCharts=false` returns each chart's title + note only); pass `includeCharts=true` for the full data series.\n\n**Returned with any finished edition, regardless of `sections`:** `coverage` (methodology and data-quality caveats — read these before quoting any figure) and `contains` (the list of sections this edition actually holds, so you can pick what to fetch next instead of requesting slots blind). Both are null whenever `meta.status` is not `done`, which says nothing about whether earlier editions exist — check `…/history`.\n\n**Reading the numbers:** the AI Visibility Score is 0–100 (higher is better); AI-visibility `presence` is the share of answers analysed that named a brand (higher is named more often) and is always read with its range; action `impact` is 1–4 (higher is more critical); action effort is bucketed as `effortMinutes` (`<30` | `30-120` | `>120`, or `null` when the briefing gave that action no estimate — report it as unsized rather than as quick). A project's next briefing is scheduled approximately 30 days after its last **successful** one.","operationId":"PublicBriefingController_getStrategicBriefing_v1","parameters":[{"name":"sections","required":false,"in":"query","description":"Which sections to return. Defaults to [\"hub\"] — the executive digest and navigation map. Pass specific sections to go deeper (e.g. a hub diagnosis pointer of `ai-visibility` maps to `deep-ai-visibility`), or `all` for the full document. Prefer deriving deep-<dimension> values from the hub diagnosis pointers rather than requesting slots blind — the response's `contains` array lists exactly which sections exist for this edition, in this same vocabulary.","schema":{"example":["hub"],"type":"array","items":{"type":"string","enum":["hub","actions","competitors","deep-ai-visibility","deep-ai-sources","deep-positioning","deep-pricing","deep-content","deep-tech-trust","deep-agent-readiness","deep-ai-ecosystem","deep-customer-voice","deep-funding-capital","deep-hiring-gtm","deep-landscape","deep-product-launches","deep-reliability-status","all"]}}},{"name":"includeCharts","required":false,"in":"query","description":"Include full chart series data. Defaults to false — each chart returns its title and note only, with no underlying numbers. Pass true for the full series.","schema":{"default":false,"example":false,"type":"boolean"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BriefingEnvelopeResponse"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get the current state of this project's strategic briefing","tags":["Strategic Briefing"]}},"/v1/projects/{projectId}/strategic-briefing/history":{"get":{"description":"List this project's Strategic Briefing runs, newest first — one cheap metadata row each, never briefing content.\n\nEach row carries `runId` (pass it to `…/history/{runId}` to read that edition in full), `briefingDate`, `status`, the derived `editionNumber`, and `headline` — that edition's one-line verdict, so you can pick which one to open without fetching every one.\n\n**Runs that failed or are still generating are included**, with `editionNumber`, `briefingDate` and `headline` null. A gap between two editions is therefore explained rather than left unexplained.\n\nUse this to answer \"what did we say in April\", to trace how a read has changed over time, or — when `GET /strategic-briefing` reports a `running` or `failed` status — to find the last edition that is actually readable. For the current state use `GET /strategic-briefing`, not this.\n\nPaginated with `page` and `limit`; check `pagination.hasMore` to fetch further pages. Briefings are produced roughly monthly, so this list is typically short.","operationId":"PublicBriefingController_getStrategicBriefingHistory_v1","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"example":20,"type":"number"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfBriefingHistoryItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/BriefingHistoryItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"}},"required":["items","pagination"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List this project's briefing editions","tags":["Strategic Briefing"]}},"/v1/projects/{projectId}/strategic-briefing/history/{runId}":{"get":{"description":"Get one strategic briefing edition by `runId`, in exactly the same shape as `GET /strategic-briefing`.\n\nTake the `runId` from `GET /strategic-briefing/history` (or from `meta.runId` on a current read). Accepts the same `sections` and `includeCharts` options, with the same `[\"hub\"]` default.\n\nA `runId` naming a run that failed or is still generating returns **200** with `meta.status` set accordingly and `item` null — that run genuinely produced no edition, which is an answer rather than an error. A malformed id returns 400 `invalid_run_id`; an id that does not belong to this project returns 404 `run_not_found`.\n\nUse this to read or quote a specific past edition. For the current state use `GET /strategic-briefing`.","operationId":"PublicBriefingController_getStrategicBriefingEdition_v1","parameters":[{"name":"runId","required":true,"in":"path","description":"Briefing run ID, from `meta.runId` or a history row","schema":{"example":"6a3950c06bda450a1372048e","type":"string"}},{"name":"sections","required":false,"in":"query","description":"Which sections to return. Defaults to [\"hub\"] — the executive digest and navigation map. Pass specific sections to go deeper (e.g. a hub diagnosis pointer of `ai-visibility` maps to `deep-ai-visibility`), or `all` for the full document. Prefer deriving deep-<dimension> values from the hub diagnosis pointers rather than requesting slots blind — the response's `contains` array lists exactly which sections exist for this edition, in this same vocabulary.","schema":{"example":["hub"],"type":"array","items":{"type":"string","enum":["hub","actions","competitors","deep-ai-visibility","deep-ai-sources","deep-positioning","deep-pricing","deep-content","deep-tech-trust","deep-agent-readiness","deep-ai-ecosystem","deep-customer-voice","deep-funding-capital","deep-hiring-gtm","deep-landscape","deep-product-launches","deep-reliability-status","all"]}}},{"name":"includeCharts","required":false,"in":"query","description":"Include full chart series data. Defaults to false — each chart returns its title and note only, with no underlying numbers. Pass true for the full series.","schema":{"default":false,"example":false,"type":"boolean"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BriefingEnvelopeResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get one briefing edition by id","tags":["Strategic Briefing"]}},"/v1/tools/tech-stack/scans":{"post":{"description":"Start an asynchronous tech-stack detection scan for a public domain. Detects the frontend frameworks, hosting and CDN infrastructure, CMS, analytics and growth tools, and customer engagement services in use. Returns sub-second with `item: { id, status: \"queued\", createdAt, expiresAt }` — poll `GET /v1/tools/tech-stack/scans/{scanId}` until `status` is `completed` or `failed`. Most scans complete in seconds; heavy-render sites can take up to ~90 seconds. Result and error payloads are persisted for 24h, after which the scan auto-deletes and GET returns 404. No project required. Suitable for ad-hoc competitive lookups, integration into onboarding flows, and pre-sales discovery — and the only supported shape for MCP clients (Cursor / Claude Desktop hardcode 60-second tool-call timeouts).","operationId":"PublicTechStackToolController_createScan_v1","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PTTechStackRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTechStackScanResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TechStackScanResponse"}},"required":["item"],"example":{"item":{"id":"6638e9a1b2c3d4e5f6a7b8c9","status":"queued","createdAt":"2026-05-17T14:30:00.000Z","expiresAt":"2026-05-18T14:30:00.000Z"}}}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}},"502":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiBadGatewayErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Start an async tech-stack scan","tags":["Tools"]}},"/v1/tools/tech-stack/scans/{scanId}":{"get":{"description":"Get the current state of an async tech-stack scan. Returns the scan's lifecycle (`queued` / `running` / `completed` / `failed`) along with `createdAt` and `expiresAt` always, plus `startedAt` once the runner picks it up and `completedAt` once it reaches a terminal state. On `completed` the full canonical scan response is in `item.result` (categorized technology hits with evidence). On `failed` the failure details are in `item.error` with a machine-readable `code` — currently `homepage_fetch_failed` (upstream homepage fetch threw) or `scan_failed` (any other error during scan execution). Scoped to the API key's organization — cross-tenant access, never-existed IDs, expired IDs (24h TTL), and malformed IDs all return 404 `scan_not_found` with an identical envelope to prevent existence-disclosure. Typically sub-second — safe to poll on a 2–5 second interval depending on your timeout budget.","operationId":"PublicTechStackToolController_getScan_v1","parameters":[{"name":"scanId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTechStackScanResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TechStackScanResponse"}},"required":["item"],"example":{"item":{"id":"6638e9a1b2c3d4e5f6a7b8c9","status":"completed","createdAt":"2026-05-17T14:30:00.000Z","expiresAt":"2026-05-18T14:30:00.000Z","startedAt":"2026-05-17T14:30:00.412Z","completedAt":"2026-05-17T14:30:45.218Z","result":{"domain":"stripe.com","finalUrl":"https://stripe.com/","fetchedAt":"2026-05-17T14:30:45.000Z","totalTechnologies":3,"techStack":[{"name":"Next.js","evidenceCount":2,"evidence":[{"kind":"html","source":"/_next/static/","matched":"<script src=\"/_next/static/chunks/main-app-abc123.js\""},{"kind":"header","source":"x-powered-by","matched":"Next.js"}]},{"name":"Cloudflare","evidenceCount":1,"evidence":[{"kind":"header","source":"server","matched":"cloudflare"}]}],"growthStack":[{"name":"HubSpot","evidenceCount":1,"evidence":[{"kind":"html","source":"js.hs-scripts.com","matched":"<script src=\"//js.hs-scripts.com/1234567.js\""}]}],"engagementStack":[]}}}}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get an async tech-stack scan by ID","tags":["Tools"]}},"/v1/tools/trust-signals/scans":{"post":{"description":"Start an asynchronous trust-signal analysis for a public domain. Returns a tier verdict (comprehensive / substantial / moderate / minimal) with a 0-100 score and a per-category breakdown across five buyer-concern areas: enterprise readiness, third-party validation, social proof, brand authority, and risk reversal. Also surfaces suspicious patterns (e.g. unverified compliance claims, count-vs-logo mismatches, unlinked press mentions) and gaps versus tier benchmarks. Returns sub-second with `item: { id, status: \"queued\", createdAt, expiresAt }` — poll `GET /v1/tools/trust-signals/scans/{scanId}` until `status` is `completed` or `failed`. Most scans complete in seconds; heavy-render sites can take up to ~90 seconds. Result and error payloads are persisted for 24h, after which the scan auto-deletes and GET returns 404. No project required. Suitable for sales discovery, due-diligence flows, and competitive credibility audits — and the only supported shape for MCP clients (Cursor / Claude Desktop hardcode 60-second tool-call timeouts).","operationId":"PublicTrustSignalsToolController_createScan_v1","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PTTrustSignalsRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTrustSignalsScanResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TrustSignalsScanResponse"}},"required":["item"],"example":{"item":{"id":"6638e9a1b2c3d4e5f6a7b8c9","status":"queued","createdAt":"2026-05-17T14:30:00.000Z","expiresAt":"2026-05-18T14:30:00.000Z"}}}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}},"502":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiBadGatewayErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Start an async trust-signals scan","tags":["Tools"]}},"/v1/tools/trust-signals/scans/{scanId}":{"get":{"description":"Get the current state of an async trust-signals scan. Returns the scan's lifecycle (`queued` / `running` / `completed` / `failed`) along with `createdAt` and `expiresAt` always, plus `startedAt` once the runner picks it up and `completedAt` once it reaches a terminal state. On `completed` the full canonical scan response is in `item.result` (tier verdict, category scores, detected signals with evidence, suspicious patterns, gaps). Roughly 1% of sites run behavioral protection that hides response headers from us. Those scans carry `item.result.headerInspection` = `{ available: false, reason: \"site_uses_behavioral_protection\" }`, and the result beside it is COMPLETE — the page body was read in full, all 34 trust-signal rules read the body and none reads headers, so the tier, score, category breakdown and `meta.signalsEvaluated` are exact and comparable against any other domain. Report them as ordinary results; the marker only means no `evidence` entry can carry `kind: \"header\"`. A `null` in `verdict`, `categoryScores`, `signalsDetected`, `suspiciousPatterns`, `gapsVsBenchmark` or `meta.signalsEvaluated` therefore means the page was never inspected at all, and appears only on scans stored before this behavior shipped (results persist 24h). It never means the site scored zero, has no trust signals, or sits in the `minimal` tier. An empty ARRAY is the opposite case and is a real finding: we looked and found none. On `failed` the failure details are in `item.error` with a machine-readable `code` — currently `homepage_fetch_failed` (upstream homepage fetch threw) or `scan_failed` (any other error during scan execution). Scoped to the API key's organization — cross-tenant access, never-existed IDs, expired IDs (24h TTL), and malformed IDs all return 404 `scan_not_found` with an identical envelope to prevent existence-disclosure. Typically sub-second — safe to poll on a 2–5 second interval depending on your timeout budget.","operationId":"PublicTrustSignalsToolController_getScan_v1","parameters":[{"name":"scanId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTrustSignalsScanResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TrustSignalsScanResponse"}},"required":["item"],"example":{"item":{"id":"6638e9a1b2c3d4e5f6a7b8c9","status":"completed","createdAt":"2026-05-17T14:30:00.000Z","expiresAt":"2026-05-18T14:30:00.000Z","startedAt":"2026-05-17T14:30:00.412Z","completedAt":"2026-05-17T14:30:02.840Z","result":{"domain":"stripe.com","finalUrl":"https://stripe.com/","fetchedAt":"2026-05-17T14:30:02.000Z","verdict":{"tier":"substantial","score":38,"benchmarkForTier":34,"benchmarkComparison":"above-average","summary":"Substantial trust coverage — strong social proof and some compliance signals, above-average for tier"},"categoryScores":{"enterpriseReadiness":{"score":8,"max":25,"signalsFound":1},"thirdPartyValidation":{"score":12,"max":20,"signalsFound":3},"socialProof":{"score":10,"max":20,"signalsFound":2},"brandAuthority":{"score":5,"max":20,"signalsFound":1},"riskReversal":{"score":3,"max":15,"signalsFound":1}},"signalsDetected":[{"signal":"SOC 2","category":"enterpriseReadiness","weight":8,"confidence":"high","evidenceCount":1,"evidence":[{"kind":"html","source":"soc 2","matched":"SOC 2 Type II compliant"}]}],"suspiciousPatterns":[],"gapsVsBenchmark":[{"signal":"Customer logos","category":"socialProof","benchmarkPrevalence":"87% of substantial-tier peers have this","ciInsight":"Add customer logos to homepage — table stakes for substantial-tier SaaS positioning"}],"meta":{"signalsEvaluated":34,"benchmarkSource":"CompetLab SaaS Trust Benchmarks 2026-Q2 (n=247)","scanDurationMs":2840}}}}}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get an async trust-signals scan by ID","tags":["Tools"]}},"/v1/tools/ai-crawler-checker":{"post":{"description":"Check which AI ASSISTANTS can fetch a public domain's pages, read from its robots.txt. `assistantAccess` is the answer: one verdict per assistant — ChatGPT, Claude, Perplexity, Microsoft Copilot, Google AI Overviews, Gemini Apps — with the crawlers that decided each named beside it. Count that array for totals; no count is stored and there is deliberately no overall score, because a weighted index has no unit and shifts when our crawler catalog moves rather than when the site does. Every tracked crawler is also returned individually with what it is FOR, the line of robots.txt that decided it, and whether a rule NAMED it or a `User-agent: *` catch-all swept it up — the second is usually accidental and is the more actionable finding. `modelTrainingAccess` is reported separately and NEUTRALLY: blocking training crawlers is a legitimate content decision and is never a gap, with one mechanical exception the response documents. This reads permission, not outcomes — it never claims a site is or is not cited. When robots.txt cannot be read, the read outcome is returned and the entire verdict layer is ABSENT rather than assumed. Optionally tailor the industry context. Stateless live check — no project required, no data is persisted.","operationId":"PublicAiCrawlerCheckerToolController_detectAiCrawlers_v1","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PTAiCrawlerCheckerRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfAiCrawlerCheckerToolResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/AiCrawlerCheckerToolResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}},"502":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiBadGatewayErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Check AI crawler accessibility for a domain","tags":["Tools"]}},"/v1/tools/sitemap-visualizer":{"post":{"description":"Discover and analyze the sitemap structure of any public domain. Reads the conventional /sitemap.xml AND every Sitemap: directive robots.txt declares — a site publishing several sitemaps returns all of them, deduplicated (or pass an explicit URL to read just that one). Categorizes URLs by content type, validates structure, and surfaces freshness and depth-distribution insights with sample URLs per category. Optionally include the full URL list (off by default to keep payload bounded). Where a declared sitemap could not be retrieved, `truncated` is true and `unreadSitemaps` names it, so a total is never reported as complete over a corpus only partly read. Stateless live check — no project required, no data is persisted. Suitable for content-strategy audits, SEO discovery, and content-volume benchmarking.","operationId":"PublicSitemapVisualizerToolController_analyzeSitemap_v1","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PTSitemapVisualizerRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfSitemapVisualizerToolResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/SitemapVisualizerToolResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}},"502":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiBadGatewayErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Analyze sitemap structure of a domain","tags":["Tools"]}},"/v1/tools/agent-adoption/scans":{"post":{"description":"Start an asynchronous AI agent-adoption scan for a public domain. Runs 25 checks across four categories — `discoverability` (robots.txt, sitemap.xml, Link headers), `accessControl` (AI bot rules, content signals, web-bot-auth, robots-allow-all), `contentReadability` (/llms.txt, markdown negotiation, rendering strategy, page size, redirects, agents.md, cache headers), and `agentEndpoints` (OpenAPI catalog, OAuth discovery, MCP server card, A2A agent card, agent skills). Returns sub-second with `item: { id, status: \"queued\", createdAt, expiresAt }` — poll `GET /v1/tools/agent-adoption/scans/{scanId}` until `status` is `completed` or `failed`. Most scans complete in 35–60 seconds; slow or JS-heavy domains can take up to ~3 minutes. Result and error payloads are persisted for 24h, after which the scan auto-deletes and GET returns 404. No project required. Suitable for AI-adoption audits, agent-adoption maturity assessments, and pre-launch agentic-commerce reviews — and the only supported shape for MCP clients (Cursor / Claude Desktop hardcode 60-second tool-call timeouts).","operationId":"PublicAgentAdoptionToolController_createScan_v1","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PTAgentAdoptionRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfAgentAdoptionScanResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/AgentAdoptionScanResponse"}},"required":["item"],"example":{"item":{"id":"6638e9a1b2c3d4e5f6a7b8c9","status":"queued","createdAt":"2026-05-17T14:30:00.000Z","expiresAt":"2026-05-18T14:30:00.000Z"}}}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}},"502":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiBadGatewayErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Start an async agent-adoption scan","tags":["Tools"]}},"/v1/tools/agent-adoption/scans/{scanId}":{"get":{"description":"Get the current state of an async agent-adoption scan. Returns the scan's lifecycle (`queued` / `running` / `completed` / `failed`) along with `createdAt` and `expiresAt` always, plus `startedAt` once the runner picks it up and `completedAt` once it reaches a terminal state. On `completed` the full canonical scan response is in `item.result`. On `failed` the failure details are in `item.error` with a machine-readable `code` — currently `homepage_fetch_failed` (upstream homepage fetch threw) or `scan_failed` (any other error during scan execution). Scoped to the API key's organization — cross-tenant access, never-existed IDs, expired IDs (24h TTL), and malformed IDs all return 404 `scan_not_found` with an identical envelope to prevent existence-disclosure. Typically sub-second — safe to poll on a 2–5 second interval depending on your timeout budget.","operationId":"PublicAgentAdoptionToolController_getScan_v1","parameters":[{"name":"scanId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfAgentAdoptionScanResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/AgentAdoptionScanResponse"}},"required":["item"],"example":{"item":{"id":"6638e9a1b2c3d4e5f6a7b8c9","status":"completed","createdAt":"2026-05-17T14:30:00.000Z","expiresAt":"2026-05-18T14:30:00.000Z","startedAt":"2026-05-17T14:30:00.412Z","completedAt":"2026-05-17T14:30:08.420Z","result":{"specVersion":"1.0.0","profile":"b2b-saas","scanner":{"name":"CompetLab Agent-Adoption Scanner","version":"0.8.0"},"domain":"stripe.com","finalUrl":"https://stripe.com/","scannedAt":"2026-05-17T14:30:08.000Z","score":72,"level":2,"levelName":"AI-Aware","categoryReports":[{"category":"discoverability","description":"Whether AI agents can find your site through standard discovery mechanisms (robots.txt, sitemaps, link headers).","score":100,"passed":1,"failed":0,"neutral":0,"couldNotMeasure":0,"checks":[{"id":"robots-txt-exists","category":"discoverability","status":"pass","scored":true,"weight":10,"message":"robots.txt found at https://stripe.com/robots.txt with 3 user-agent rules","description":"Verifies a /robots.txt file exists and is fetchable.","durationMs":142,"dependsOn":[],"specUrls":["https://www.rfc-editor.org/rfc/rfc9309"]}]},{"category":"accessControl","description":"Whether your access rules deliberately allow or block AI agents.","score":100,"passed":1,"failed":0,"neutral":0,"couldNotMeasure":0,"checks":[{"id":"ai-bot-rules","category":"accessControl","status":"pass","scored":true,"weight":7,"message":"Explicit rules found for 4 known AI user-agents","description":"Checks whether robots.txt declares explicit rules for known AI crawlers.","durationMs":38,"dependsOn":["robots-txt-exists"],"specUrls":["https://platform.openai.com/docs/bots"]}]},{"category":"contentReadability","description":"Whether your content is structured for efficient agent consumption.","score":0,"passed":0,"failed":1,"neutral":0,"couldNotMeasure":0,"checks":[{"id":"llms-txt-exists","category":"contentReadability","status":"fail","scored":true,"weight":4,"message":"No /llms.txt file found","description":"Verifies a /llms.txt file exists at the site root.","durationMs":51,"dependsOn":[],"specUrls":["https://llmstxt.org/"]}]},{"category":"agentEndpoints","description":"Whether you expose machine-readable agent endpoints (MCP, A2A, OAuth discovery).","score":0,"passed":0,"failed":0,"neutral":1,"couldNotMeasure":0,"checks":[{"id":"mcp-server-card","category":"agentEndpoints","status":"neutral","scored":false,"weight":0,"message":"No MCP server card detected (informational)","description":"Looks for a published MCP server card advertising agent tools.","durationMs":44,"dependsOn":[],"specUrls":["https://modelcontextprotocol.io/"]}]}],"clusters":[{"name":"htmlPath","kind":"coefficient","triggered":true,"coefficient":1,"appliesTo":["rendering-strategy","page-size-html"],"message":"Standard HTML path detected — no rendering penalties applied."},{"name":"spaRenderingCap","kind":"cap","triggered":false,"message":"No SPA rendering cap applied."},{"name":"noViablePathCap","kind":"cap","triggered":false,"message":"A viable agent path exists — no cap applied."}],"meta":{"checksEvaluated":25,"checksSkipped":0,"scanDurationMs":8420,"counters":{"fetches":{"total":28,"success":22,"wafBlocked":0,"notFound":5,"failed":1},"heavyFetchCalls":{"total":2,"success":2,"unavailable":0}}}}}}}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiNotFoundErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get an async agent-adoption scan by ID","tags":["Tools"]}},"/v1/tools/fetch-url":{"post":{"description":"Fetches the body and headers of any URL with automatic JavaScript rendering and common bot-protection handling. Tune via optional parameters (`bodyNeeded`, `headersNeeded`, `cleanHtml`, `maxTimeoutMs`, `bodyMaxBytes`). Response includes `body`, `headers`, `cleanStats`. The `cleanHtml` flag strips HTML noise (scripts, styles, comments) while preserving text content — significant token-cost reduction for LLM consumption; per-request reduction reported in `cleanStats`. Returns `headersAvailable: false` when advanced behavioral fingerprinting prevents header access. Intended for AI agents and customer integrations that need direct URL content access.","operationId":"PublicFetchUrlToolController_fetchUrl_v1","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PTFetchUrlRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfFetchUrlToolResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/FetchUrlToolResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}},"502":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiBadGatewayErrorEnvelope"}}}},"503":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiServiceUnavailableErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Fetch URL body and headers with JS-rendering + bot-protection handling","tags":["Tools"]}}},"info":{"title":"CompetLab API","description":"\nWelcome to the CompetLab API documentation. This API provides programmatic access to your competitive intelligence data — technology stacks, content strategy, messaging & positioning, pricing plans, AI brand visibility across ChatGPT, Claude, Gemini, Perplexity, and Google AI Overviews, and — for Perplexity and Google AI Overviews — the pages they read when they answer your buyers' questions.\n\n### Who is this for?\n\n- **Developers** building integrations with internal tools, CRMs, or BI platforms\n- **Agencies** building whitelabel competitive dashboards for their clients\n- **AI agents and MCP servers** consuming structured competitive data\n- **Automation platforms** (Zapier, n8n) monitoring competitor changes\n\n### Authentication\n\nAll endpoints require a `CL-API-Key` header. Generate your API key in the CompetLab app under **Your Organization → Settings → API Keys**.\n\n```\nCL-API-Key: cl_live_a1b2c3d4e5f6...\n```\n\nKeys are organization-scoped — one key accesses all projects in your organization. Two permission scopes exist: `read` (current API) and `read_write` (future).\n\nTo manage your keys (create, revoke, set expiration): **Your Organization → Settings → API Keys**.\n\n### Response Format\n\nEvery response follows one of three shapes:\n\n**Single resource:**\n```json\n{ \"item\": { \"id\": \"...\", \"name\": \"...\" } }\n```\n\n**Collection (with optional pagination):**\n```json\n{ \"items\": [...], \"pagination\": { \"page\": 1, \"limit\": 20, \"total\": 47, \"totalPages\": 3, \"hasMore\": true } }\n```\n\n**Error:**\n```json\n{ \"error\": { \"code\": \"project_not_found\", \"message\": \"Project not found\", \"status\": 404 } }\n```\n\nPagination uses `?page=1&limit=20` (1-indexed, default 20, max 100).\n\n### Error Codes\n\nAll errors return a machine-readable `code` field for programmatic handling:\n\n| Code | Status | Description |\n|------|--------|-------------|\n| `api_key_missing` | 401 | No `CL-API-Key` header provided |\n| `api_key_invalid` | 401 | Key not found or malformed |\n| `api_key_revoked` | 401 | Key has been revoked |\n| `api_key_expired` | 401 | Key has expired |\n| `insufficient_scope` | 403 | Key doesn't have the required permission scope |\n| `project_not_found` | 404 | Project doesn't exist or doesn't belong to your organization |\n| `no_data_available` | 404 | No monitoring data available yet for this dimension |\n| `not_found` | 404 | Requested resource not found |\n| `invalid_parameters` | 400 | Invalid query parameters or request format |\n| `rate_limit_exceeded` | 429 | Rate limit exceeded for this API key |\n| `internal_error` | 500 | Unexpected server error — retry or contact support |\n| `bad_gateway` | 502 | Upstream service returned an invalid response — retry with backoff |\n| `service_unavailable` | 503 | Service temporarily unavailable — retry with backoff |\n| `gateway_timeout` | 504 | Upstream service timed out — retry with backoff |\n\n**Note on 5xx responses:** The `message` field for 5xx errors is a static string (`\"Internal server error\"`). Branch on `code` for programmatic handling — do not pattern-match on `message` for 5xx.\n\n### Monitoring Dimensions\n\nCompetLab monitors six dimensions of competitive intelligence:\n\n| Dimension | Route | What it monitors |\n|-----------|-------|-----------------|\n| **Tech & Trust Profile** | `/tech-trust` | Technology stack, security headers, trust signals, DNS, robots.txt |\n| **Content Intelligence** | `/content` | Sitemap structure, URL volume, content categories, URL changes |\n| **Positioning** | `/positioning` | Homepage messaging, value propositions, CTAs, target audience |\n| **Pricing Intelligence** | `/pricing` | Pricing plans, tiers, free trials, enterprise options, promotions |\n| **AI Visibility** | `/ai-visibility` | Brand mentions across ChatGPT, Claude, Gemini, Perplexity, and Google AI Overviews — with the rankings the four chat models gave |\n| **AI Sources** | `/ai-sources` | The pages Perplexity and Google AI Overviews read when they answer your buyers' questions, and whether you are on them — per engine, never pooled |\n\nEach dimension has three endpoints: a **dashboard** (latest monitoring data), **history** (paginated past runs), and **run detail** (full data for a specific historical run).\n\n### Strategic Briefing\n\nBeyond the raw per-dimension data, `/strategic-briefing` returns the latest **synthesized** competitive briefing — the as-of read of what changed, what it means, and what to do, with an executive hub, deep-dives, a competitor dossier, and a ranked action plan. It synthesizes a **broader analytical set than the monitored dashboards it reads** — all six above, plus eight it researches for the briefing alone: landscape, funding, hiring/GTM, product launches and more. It is lean by default (request only the `sections` you need — it defaults to the `hub`, which doubles as the navigation map) and always carries methodology `coverage` caveats. See the endpoint for the full `sections` list.\n\n### Quick Start\n\n**Step 1** — Get your API key from **Your Organization → Settings → API Keys** in the [CompetLab app](https://app.competlab.com)\n\n**Step 2** — List your projects:\n```bash\ncurl -H \"CL-API-Key: cl_live_your_key_here\" https://api.competlab.com/v1/projects\n```\n\n**Step 3** — Get the latest pricing intelligence:\n```bash\ncurl -H \"CL-API-Key: cl_live_your_key_here\" https://api.competlab.com/v1/projects/{projectId}/pricing\n```\n\n### Additional Resources\n\n- [OpenAPI JSON Spec](https://api.competlab.com/v1/docs/openapi.json) — Machine-readable API definition for code generators and integrations\n- **API Key Management** — Your Organization → Settings → API Keys in the CompetLab app\n\n### Rate Limits\n\nRate limiting is not enforced in v1. We recommend keeping requests under **60 per minute** as a best practice. Rate limits will be introduced in a future version with proper `429` responses and `Retry-After` headers.\n","version":"1.0","contact":{}},"tags":[],"servers":[{"url":"https://api.competlab.com","description":"Production"}],"components":{"securitySchemes":{"CL-API-Key":{"type":"apiKey","in":"header","name":"CL-API-Key","description":"API key generated in the CompetLab dashboard under Settings → API Keys. Pass as CL-API-Key header."}},"schemas":{"HealthResponse":{"type":"object","properties":{"status":{"type":"string","description":"API status","example":"ok"},"timestamp":{"type":"string","description":"Current server timestamp in ISO-8601 format","example":"2026-03-15T10:00:00.000Z"}},"required":["status","timestamp"]},"ApiUnauthorizedErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable authentication error code.","enum":["api_key_missing","api_key_invalid","api_key_revoked","api_key_expired","insufficient_scope"],"example":"api_key_invalid"},"message":{"type":"string","description":"Human-readable error message","example":"Invalid API key"},"status":{"type":"number","description":"HTTP status code","example":401}},"required":["code","message","status"]},"ApiUnauthorizedErrorEnvelope":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/ApiUnauthorizedErrorResponse"}},"required":["error"]},"ProjectListItemResponse":{"type":"object","properties":{"id":{"type":"string","description":"Project ID","example":"507f1f77bcf86cd799439011"},"organizationId":{"type":"string","description":"Organization ID that owns this project","example":"507f1f77bcf86cd799439011"},"name":{"type":"string","description":"Project name","example":"My SaaS Product"},"domain":{"type":"string","description":"Monitored domain","example":"mycompany.com"},"status":{"type":"string","description":"Project status","enum":["market_discovering","active"],"example":"active"},"competitorCount":{"type":"number","description":"Number of monitored competitors","example":5},"lastMonitoredAt":{"type":"string","description":"Most recent completed monitoring run (ISO-8601), or null if nothing has completed. For `aiVisibility`, only checks that published a measurement count toward this — see `dimensions.aiVisibility.lastRunAt`.","example":"2026-03-15T10:00:00.000Z","nullable":true},"createdAt":{"type":"string","description":"Project creation date (ISO-8601)","example":"2026-01-10T08:30:00.000Z"}},"required":["id","organizationId","name","domain","status","competitorCount","lastMonitoredAt","createdAt"]},"DimensionLastRunResponse":{"type":"object","properties":{"lastRunAt":{"type":"string","description":"Timestamp of the last completed run for this dimension (ISO-8601), or null if there is none. For `techTrust`, `content`, `positioning` and `pricing`, null means no run has completed. For `aiVisibility` this is instead the last check that published a measurement: a cycle that ran but had a query it could not read is abandoned and never moves this timestamp (a query the model was read for and had no answer to show — `noAnswerShown` — does not count against it), so neither an unchanged value nor null proves that nothing ran. That cycle is still reported — `/ai-visibility` carries `latestCheckDataAvailable` when an earlier check has published, and `/ai-visibility/trend` reports it under `events.incompleteCycles`, the only surface that reports it when no check has ever published, since `/ai-visibility` then returns 404 `no_data_available`. For `aiSources` the same rule holds on its own checks: the last check that published, never an abandoned cycle (no engine produced a usable answer, or the page stage could not be closed) — `/ai-sources` carries `latestCheckDataAvailable` for that case once an earlier check has published.","example":"2026-03-14T18:30:00.000Z","nullable":true}},"required":["lastRunAt"]},"DimensionFreshnessResponse":{"type":"object","properties":{"techTrust":{"description":"Technology stack, security headers, trust signals, and DNS monitoring","allOf":[{"$ref":"#/components/schemas/DimensionLastRunResponse"}]},"content":{"description":"Website structure, URL discovery, and content change detection","allOf":[{"$ref":"#/components/schemas/DimensionLastRunResponse"}]},"positioning":{"description":"Homepage messaging, value propositions, CTAs, and competitive positioning","allOf":[{"$ref":"#/components/schemas/DimensionLastRunResponse"}]},"pricing":{"description":"Pricing plans, tiers, features, and promotional offers","allOf":[{"$ref":"#/components/schemas/DimensionLastRunResponse"}]},"aiVisibility":{"description":"AI brand mentions across ChatGPT, Claude, Gemini, Perplexity, and Google AI Overviews","allOf":[{"$ref":"#/components/schemas/DimensionLastRunResponse"}]},"aiSources":{"description":"The pages Perplexity and Google AI Overviews read when they answer your buyers' questions, and whether you are on them","allOf":[{"$ref":"#/components/schemas/DimensionLastRunResponse"}]}},"required":["techTrust","content","positioning","pricing","aiVisibility","aiSources"]},"ProjectDetailResponse":{"type":"object","properties":{"id":{"type":"string","description":"Project ID","example":"507f1f77bcf86cd799439011"},"organizationId":{"type":"string","description":"Organization ID that owns this project","example":"507f1f77bcf86cd799439011"},"name":{"type":"string","description":"Project name","example":"My SaaS Product"},"domain":{"type":"string","description":"Monitored domain","example":"mycompany.com"},"status":{"type":"string","description":"Project status","enum":["market_discovering","active"],"example":"active"},"competitorCount":{"type":"number","description":"Number of monitored competitors","example":5},"lastMonitoredAt":{"type":"string","description":"Most recent completed monitoring run (ISO-8601), or null if nothing has completed. For `aiVisibility`, only checks that published a measurement count toward this — see `dimensions.aiVisibility.lastRunAt`.","example":"2026-03-15T10:00:00.000Z","nullable":true},"createdAt":{"type":"string","description":"Project creation date (ISO-8601)","example":"2026-01-10T08:30:00.000Z"},"dimensions":{"description":"Per-dimension last run timestamps","allOf":[{"$ref":"#/components/schemas/DimensionFreshnessResponse"}]},"prompts":{"description":"The project's 3 AI monitoring prompt texts — the questions put to each model. Always exactly 3; a project cannot be created or edited to hold any other number.","example":["Best CRM for small business","Which CRM platforms integrate with Slack?","Affordable CRM for a 10-person sales team"],"type":"array","items":{"type":"string"}},"promptsLastChangedAt":{"type":"string","description":"When prompts were last modified (ISO-8601), or null if never changed","example":"2026-02-20T14:00:00.000Z","nullable":true}},"required":["id","organizationId","name","domain","status","competitorCount","lastMonitoredAt","createdAt","dimensions","prompts","promptsLastChangedAt"]},"ApiNotFoundErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable code identifying which resource was not found. The last two are NOT missing resources and must not be reported as one: `run_not_summarized` — the run exists and completed, but produced no published summary, which is a different fact from `run_not_found`; `no_data_available` — the project has no published data for this dimension yet.","enum":["project_not_found","competitor_not_found","run_not_found","check_not_found","scan_not_found","run_not_summarized","no_data_available"],"example":"scan_not_found"},"message":{"type":"string","description":"Human-readable error message","example":"Scan not found"},"status":{"type":"number","description":"HTTP status code","example":404}},"required":["code","message","status"]},"ApiNotFoundErrorEnvelope":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/ApiNotFoundErrorResponse"}},"required":["error"]},"CompetitorListItemResponse":{"type":"object","properties":{"id":{"type":"string","description":"Competitor ID","example":"507f1f77bcf86cd799439011"},"domain":{"type":"string","description":"Competitor domain","example":"competitor.com"},"isOwn":{"type":"boolean","description":"Whether this is the project's own domain","example":false},"preparationStatus":{"type":"string","description":"Data preparation status","enum":["pending","in_progress","complete"],"example":"complete"},"createdAt":{"type":"string","description":"When the competitor was created (ISO-8601)","example":"2026-01-15T12:00:00.000Z"}},"required":["id","domain","isOwn","preparationStatus","createdAt"]},"MonitoredPagesResponse":{"type":"object","properties":{"homepage":{"type":"string","description":"Homepage URL, or null if not identified","example":"https://competitor.com/","nullable":true},"pricing":{"type":"string","description":"Pricing page URL, or null if not identified","example":"https://competitor.com/pricing","nullable":true}},"required":["homepage","pricing"]},"CompetitorDetailResponse":{"type":"object","properties":{"id":{"type":"string","description":"Competitor ID","example":"507f1f77bcf86cd799439011"},"domain":{"type":"string","description":"Competitor domain","example":"competitor.com"},"isOwn":{"type":"boolean","description":"Whether this is the project's own domain","example":false},"preparationStatus":{"type":"string","description":"Data preparation status","enum":["pending","in_progress","complete"],"example":"complete"},"createdAt":{"type":"string","description":"When the competitor was created (ISO-8601)","example":"2026-01-15T12:00:00.000Z"},"monitoredPages":{"description":"Monitored page URLs for this competitor","allOf":[{"$ref":"#/components/schemas/MonitoredPagesResponse"}]}},"required":["id","domain","isOwn","preparationStatus","createdAt","monitoredPages"]},"SecurityGrade":{"type":"string","enum":["A","B","C","D","F"],"description":"Your security grade, or null if check failed"},"SignalsAvailableUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false for this discriminator.","example":false},"reason":{"type":"string","description":"Why signals are unavailable. Known sub-classes are documented; new behavioral-protection categories may be classified over time.","enum":["site_uses_behavioral_protection"],"example":"site_uses_behavioral_protection"}},"required":["available","reason"]},"TechTrustUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false — present only when your own domain could not be analyzed","example":false},"reason":{"type":"string","description":"Why your own tech & trust profile is unmeasured this run. 'tech_trust_fetch_failed' = we couldn't reach your domain to read its security, tech, and trust signals (DNS failure, timeout, or an error response). 'tech_trust_domain_not_configured' = no valid domain is on file for your own site, so no check ran.","example":"tech_trust_fetch_failed","enum":["tech_trust_fetch_failed","tech_trust_domain_not_configured"]}},"required":["available","reason"]},"SummaryCustomerResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Your domain","example":"yoursite.com"},"securityGrade":{"description":"Your security grade, or null if check failed","nullable":true,"example":"B","allOf":[{"$ref":"#/components/schemas/SecurityGrade"}]},"securityScore":{"type":"number","description":"Your security score (0-100), or null if check failed","nullable":true,"example":72},"securitySignalsAvailable":{"description":"Honest-degradation discriminator. Present only when your own domain runs behavioral protection that blocks header inspection (~1.1% of sites). When present, `securityGrade` and `securityScore` above are forced to `null` so dashboards never surface F-grade placeholder values. Absent on healthy scans (the normal path).","allOf":[{"$ref":"#/components/schemas/SignalsAvailableUnavailableResponse"}]},"trustSignalCount":{"type":"number","description":"Number of trust signals detected on your homepage, or null when your own domain couldn't be analyzed this run. A `0` is a MEASURED finding — we read your homepage and found none of the signals we look for — so never read a null as 0. Still measured (not null) on a shielded check: trust signals come from the HTML, so they survive a `securitySignalsAvailable` degradation.","example":8,"nullable":true},"techStackCount":{"type":"number","description":"Number of technologies detected on your domain, or null when your own domain couldn't be analyzed this run. A `0` is a measured finding; never read a null as 0. Survives a shielded check like `trustSignalCount`, but not on the same terms: when `securitySignalsAvailable` is present this count is a FLOOR rather than a total, because your hosting and CDN could not be detected either (both are read from the same blocked response headers). Trust signals come wholly from the HTML and are unaffected. See `competitors[].technologyStack.partialDetection` for the full membership of what went unlooked-for.","example":14,"nullable":true},"techTrustAnalysisAvailable":{"description":"Honest-degradation discriminator. Present only when your own domain couldn't be analyzed this run — see `reason`. When present, EVERY metric in this object is null — `securityGrade`, `securityScore`, `trustSignalCount`, `techStackCount` — and so are `securityScoreGap` and `trustSignalGap`. Nothing here is a measured fact; the nulls say that on their own, and this field records why. Only `domain` remains populated, and only when a domain was on file. Distinct from `securitySignalsAvailable`, which is a PARTIAL degradation: there the check ran and only the header-derived numbers are unknown, so the trust and tech counts stay measured. Absent on healthy scans (the normal path).","allOf":[{"$ref":"#/components/schemas/TechTrustUnavailableResponse"}]}},"required":["domain","securityGrade","securityScore","trustSignalCount","techStackCount"]},"SummaryTopSecurityResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Top security competitor domain","example":"rival.com"},"securityGrade":{"description":"Their security grade","example":"A","allOf":[{"$ref":"#/components/schemas/SecurityGrade"}]},"securityScore":{"type":"number","description":"Their security score (0-100)","example":95}},"required":["domain","securityGrade","securityScore"]},"SummaryTopTrustResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Top trust signals competitor domain","example":"other.com"},"trustSignalCount":{"type":"number","description":"Their trust signal count","example":15}},"required":["domain","trustSignalCount"]},"ComparisonState":{"type":"string","enum":["sole_among_tracked","some_competitors_unmeasured","no_competitor_measured","no_competitors_tracked","customer_unmeasured","compared"],"description":"How to read `trustSignalGap`, so you do not have to infer it. `compared` — an ordinary comparison against a measured field. `sole_among_tracked` — every one of the tracked competitors has a usable check in this run's data, all of them displaying zero homepage trust signals, and you do not: you are the only one, a measured finding and not missing data. `some_competitors_unmeasured` — at least one tracked competitor produced no usable check, so the comparison is INCOMPLETE, and that is all this state says: a measured competitor may still be leading, so read `trustSignalGap` and `topTrustCompetitor` before describing what the measured ones showed. `no_competitor_measured` — competitors are tracked and every one of their checks failed. `no_competitors_tracked` — no competitors are tracked, so nothing was attempted; this is not a failure. `customer_unmeasured` — your own domain could not be read this run, so there is no comparison to describe. Absent on runs recorded before this field shipped, which means the state was not computed — never read an absent value as `compared`. Two limits bound every reading. The set is YOUR TRACKED COMPETITORS — a list you chose, not a sample of a market — so it licenses nothing about your category or any vendor not on it: not 'no competitor offers this', not 'the only vendor', not 'unique in the market', not 'market leader'. And this dimension measures what a vendor DISPLAYS on its homepage, so the honest claim is always 'none of them displays it'; a vendor can hold SOC 2 and never badge it, and a zero here says nothing about what they hold."},"TechTrustDashboardSummaryResponse":{"type":"object","properties":{"competitorsChecked":{"type":"number","description":"Number of competitors checked in this run","example":6},"competitorsSuccessful":{"type":"number","description":"Number of competitors successfully checked","example":5},"customer":{"description":"Your domain's key metrics","allOf":[{"$ref":"#/components/schemas/SummaryCustomerResponse"}]},"topSecurityCompetitor":{"description":"Competitor with the highest security score, or null if no competitors checked","nullable":true,"allOf":[{"$ref":"#/components/schemas/SummaryTopSecurityResponse"}]},"topTrustCompetitor":{"description":"Competitor with the highest trust-signal count, or null if no competitors were checked. Ties are broken alphabetically by domain, so a `trustSignalCount` of `0` here means every competitor was measured and none publishes a single trust signal — a real finding, not a missing one. Don't describe this domain as leading unless its count is actually higher than the others'.","nullable":true,"allOf":[{"$ref":"#/components/schemas/SummaryTopTrustResponse"}]},"securityScoreGap":{"type":"number","description":"Your security score minus the top competitor's (negative = you are behind), or null when there is no comparison to make: your own score is unmeasured (whole domain unanalyzed, or headers shielded — the score is header-derived), or no competitor was scored at all. A `0` means genuinely tied. Was a forced `0` before, which read as 'tied' either way.","example":-23,"nullable":true},"trustSignalGap":{"type":"number","description":"Your trust signal count minus the top competitor's (negative = you are behind), or null when there is no comparison to make: your own domain was unanalyzed, or no competitor was checked at all. A field of competitors all measured at zero is a real comparison and yields a real number — your own count, positive — not a null. A `0` means genuinely tied. Unlike `securityScoreGap`, this survives a shielded check — trust signals come from the HTML.","example":-7,"nullable":true},"trustComparisonState":{"description":"How to read `trustSignalGap`, so you do not have to infer it. `compared` — an ordinary comparison against a measured field. `sole_among_tracked` — every one of the tracked competitors has a usable check in this run's data, all of them displaying zero homepage trust signals, and you do not: you are the only one, a measured finding and not missing data. `some_competitors_unmeasured` — at least one tracked competitor produced no usable check, so the comparison is INCOMPLETE, and that is all this state says: a measured competitor may still be leading, so read `trustSignalGap` and `topTrustCompetitor` before describing what the measured ones showed. `no_competitor_measured` — competitors are tracked and every one of their checks failed. `no_competitors_tracked` — no competitors are tracked, so nothing was attempted; this is not a failure. `customer_unmeasured` — your own domain could not be read this run, so there is no comparison to describe. Absent on runs recorded before this field shipped, which means the state was not computed — never read an absent value as `compared`. Two limits bound every reading. The set is YOUR TRACKED COMPETITORS — a list you chose, not a sample of a market — so it licenses nothing about your category or any vendor not on it: not 'no competitor offers this', not 'the only vendor', not 'unique in the market', not 'market leader'. And this dimension measures what a vendor DISPLAYS on its homepage, so the honest claim is always 'none of them displays it'; a vendor can hold SOC 2 and never badge it, and a zero here says nothing about what they hold.","example":"compared","allOf":[{"$ref":"#/components/schemas/ComparisonState"}]},"comparableCompetitors":{"type":"number","description":"Tracked competitors that produced a usable check this run, excluding your own domain. The sample `trustSignalGap` was drawn from, and the denominator behind `trustComparisonState` — quote it whenever you quote the state, so a reader knows how large 'all of them' was. Absent on runs recorded before this field shipped, which means the figure was never computed; a real `0` is different and means every tracked competitor's check failed, and that run says so with `trustComparisonState: no_competitor_measured`.","example":4}},"required":["competitorsChecked","competitorsSuccessful","customer","securityScoreGap","trustSignalGap"]},"SecurityHeadersResponse":{"type":"object","properties":{"grade":{"description":"Overall security grade, or null when this site runs behavioral protection that blocked header inspection (see signalsAvailable). A null is not an F.","example":"A","nullable":true,"allOf":[{"$ref":"#/components/schemas/SecurityGrade"}]},"score":{"type":"number","description":"Security score (0-100), or null when headers could not be inspected. Never read a null as 0 — the stored fallback really was a 0, which is why this is nulled at the boundary rather than passed through.","example":85,"nullable":true},"hsts":{"type":"boolean","description":"Has the HTTP Strict Transport Security header, or null when headers could not be inspected. A measured false means the header is genuinely absent.","example":true,"nullable":true},"csp":{"type":"boolean","description":"Has the Content Security Policy header, or null when headers could not be inspected. A measured false means the header is genuinely absent.","example":true,"nullable":true},"xFrameOptions":{"type":"boolean","description":"Has the X-Frame-Options header, or null when headers could not be inspected. A measured false means the header is genuinely absent.","example":true,"nullable":true},"xContentTypeOptions":{"type":"boolean","description":"Has the X-Content-Type-Options header, or null when headers could not be inspected. A measured false means the header is genuinely absent.","example":true,"nullable":true},"signalsAvailable":{"description":"Honest-degradation discriminator. Present only when the upstream homepage fetch couldn't recover response headers (~1.1% of sites running behavioral protection that blocks header inspection). When present, every other field in this object — `grade`, `score`, `hsts`, `csp`, `xFrameOptions`, `xContentTypeOptions` — is null. This is not a 'no security headers' result and not an F grade; the headers were unreadable, which is a property of their bot protection, not of their security. Absent on healthy scans (the normal path).","allOf":[{"$ref":"#/components/schemas/SignalsAvailableUnavailableResponse"}]}},"required":["grade","score","hsts","csp","xFrameOptions","xContentTypeOptions"]},"TrustSignalCategoriesResponse":{"type":"object","properties":{"compliance":{"type":"number","description":"Compliance signals detected, out of 6 looked for: SOC 2, ISO 27001, HIPAA, GDPR, CCPA, LGPD. A 0 means none of those specific signals was found — it is not a general statement about the site's privacy or compliance posture, and no other signal may be inferred from it. Evidence strength differs within the category, so a positive count needs care too: SOC 2, ISO 27001 and HIPAA match a rendered trust badge, but GDPR, CCPA and LGPD are text matches, so a consent banner or a footer link registers one. Report those three as \"the page mentions it\", never as a compliance posture. PIPEDA, PDPA and other regimes are not detected at all.","example":3},"reviews":{"type":"number","description":"Review-platform badges detected, out of 6 looked for: G2, Capterra, Trustpilot, Gartner, TrustRadius, GetApp. Regional review platforms outside this list are not detected, so a 0 is not evidence that a vendor has no reviews.","example":4},"socialProof":{"type":"number","description":"Social-proof signals detected, out of 5 looked for: Customer Logos, Customer Count, Case Studies, Money Back Guarantee, Free Trial. A 0 means none of those five was found on the homepage; it is not a statement about whether the vendor has customers.","example":2},"certifications":{"type":"number","description":"Certifications detected, out of 8 looked for: PCI DSS, FedRAMP, ISO 9001, ISO 14001, SOC 1, SOC 3, NIST, HITRUST. A 0 means none of those badges was DISPLAYED on the homepage — a vendor may hold any of them without publishing it, so this is never evidence that they lack a certification. Each name must match as a whole word against page text or a badge image, so a page containing \"administration\" or \"Afghanistan\" no longer registers NIST.","example":1},"disclosures":{"type":"number","description":"Disclosure links detected, out of 1 looked for: Privacy Policy. Matches a homepage link whose target or text names a privacy policy in English, Spanish, Portuguese, French, German, Italian, Dutch, Polish, Russian or Ukrainian. It is evidence the page LINKS one — never an assessment of what the policy says. A 0 means the homepage carries no such link; the vendor may still publish a policy elsewhere on the site.","example":1}},"required":["compliance","reviews","socialProof","certifications","disclosures"]},"TrustSignalsResponse":{"type":"object","properties":{"totalCount":{"type":"number","description":"Total trust signals detected on this domain, out of 26 we look for across the five categories below. These are signals published on the HOMEPAGE — a vendor may hold a certification without displaying it, so this measures visible trust-building, not the underlying facts. Most of the NAMED programmes in this list are US or EU ones: the six review platforms are the anglophone B2B SaaS review ecosystem, and the privacy regimes are GDPR, CCPA and LGPD, with PIPEDA, PDPA and others not detected. The list is not uniformly so — ISO 27001, ISO 9001, ISO 14001 and PCI DSS are held worldwide, and the social-proof and disclosure signals describe a practice rather than a programme, so they are market-neutral. That is what bounds a count: a vendor selling outside the US and EU commonly scores on the market-neutral signals and near-zero on the named programmes. Compare counts within a market rather than across markets, and when a total is low, say WHICH part is low rather than reporting the total as a level of trust. Distinct from the standalone trust-signals scan tool, which uses its own wider taxonomy and its own categories.","example":10},"categories":{"description":"Trust signals broken down by category","allOf":[{"$ref":"#/components/schemas/TrustSignalCategoriesResponse"}]}},"required":["totalCount","categories"]},"TechStackPartialDetectionResponse":{"type":"object","properties":{"headersAvailable":{"type":"boolean","description":"Always false for this discriminator.","example":false},"reason":{"type":"string","description":"Why the technology detection was partial: this site runs behavioral protection that blocked inspection of its response headers. Sixteen of our eighteen detectors read the page HTML and ran normally — the two that need headers are hosting and CDN, and these thirteen technologies became undetectable: Vercel, Netlify, AWS, Heroku, GitHub Pages, DigitalOcean, Render, Cloudflare, Fastly, Akamai, CloudFront, Bunny CDN, KeyCDN. Railway and Fly.io stay detectable because they also leave HTML traces.","enum":["site_uses_behavioral_protection"],"example":"site_uses_behavioral_protection"}},"required":["headersAvailable","reason"]},"TechnologyStackResponse":{"type":"object","properties":{"tech":{"description":"Core technology stack (frameworks, languages, infrastructure). When `partialDetection` is present this list is real but incomplete — hosting and CDN could not be detected on this domain, so read it as 'at least these', never as the full picture.","example":["React","Next.js","TypeScript"],"type":"array","items":{"type":"string"}},"growth":{"description":"Growth and marketing tools (analytics, ads, SEO). Detected from the page HTML, so this list is complete even when `partialDetection` is present.","example":["Google Analytics","HubSpot"],"type":"array","items":{"type":"string"}},"engagement":{"description":"Customer engagement tools (chat, support, CRM). Detected from the page HTML, so this list is complete even when `partialDetection` is present.","example":["Intercom","Zendesk"],"type":"array","items":{"type":"string"}},"totalCount":{"type":"number","description":"Total number of technologies detected. When `partialDetection` is present this is a FLOOR, not a total — the real figure is this number plus however many hosting and CDN technologies we could not look for. Never compare it against another domain's count, or quote it as 'N technologies against your M', while that marker is set.","example":12},"partialDetection":{"description":"Honest-degradation discriminator. Present only when this site's behavioral protection blocked inspection of its response headers (~1.1% of sites). Every technology listed above is a real detection and stays trustworthy — this is a partial measurement, not a failed one, which is why nothing in this object is null. What is missing is the hosting and CDN categories: thirteen technologies (Vercel, Netlify, AWS, Heroku, GitHub Pages, DigitalOcean, Render, Cloudflare, Fastly, Akamai, CloudFront, Bunny CDN, KeyCDN) are only detectable from response headers, and none of them could be looked for. A technology's absence from this block is therefore NOT evidence the site does not use it, and \"no CDN\" or \"no managed hosting\" must never be inferred or reported. Absent on healthy scans, which is the normal path.","allOf":[{"$ref":"#/components/schemas/TechStackPartialDetectionResponse"}]}},"required":["tech","growth","engagement","totalCount"]},"RobotsTxtUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false for this discriminator.","example":false},"reason":{"type":"string","description":"Why the robots.txt analysis is unavailable — what WE observed, never a claim about the site. 'access_transport_error' — no answer came back at all. 'access_http_error_status' — the server answered with a status that settles nothing, such as 5xx, 403 or 429. 'access_body_not_robots' — a success status carrying markup rather than a robots.txt, usually a bot-protection page. 'access_body_too_large' — the file exceeded our transfer ceiling. 'access_fallback_unavailable' — our second attempt was unavailable too. Distinct from a domain that genuinely has no robots.txt — that is a measured 'allows all crawlers' result and carries no discriminator.","enum":["access_transport_error","access_http_error_status","access_body_not_robots","access_body_too_large","access_fallback_unavailable"],"example":"access_http_error_status"}},"required":["available","reason"]},"RobotsTxtResponse":{"type":"object","properties":{"exists":{"type":"boolean","description":"Whether a robots.txt file exists, or null when the file could not be retrieved this run (see robotsTxtAvailable). A measured `false` — no discriminator alongside — is a real 'no robots.txt, so it allows all crawlers' finding and carries no marker.","example":true,"nullable":true},"totalRules":{"type":"number","description":"Total number of disallow rules, or null when the file could not be retrieved. A measured 0 means we read the file and it disallows nothing.","example":5,"nullable":true},"robotsTxtAvailable":{"description":"Honest-degradation discriminator. Present only when the robots.txt file could not be retrieved this run (network timeout or error). When present, every other field in this object — `exists`, `totalRules` — is null, and the sibling `aiAccess` block carries no verdicts at all, so no AI-access posture can be derived from either. Absent on healthy scans AND on domains that genuinely have no robots.txt: that is a measured 'allows all crawlers' result, and it arrives as `exists: false` with no marker.","allOf":[{"$ref":"#/components/schemas/RobotsTxtUnavailableResponse"}]}},"required":["exists","totalRules"]},"AiAccessSurfaceReadResponse":{"type":"object","properties":{"state":{"type":"string","description":"`read` (we looked and got an answer), `measured_absence` (we looked and the surface genuinely carries nothing — a real 404), `could_not_read` (we looked and failed), or `not_attempted` (this surface was outside what this check set out to read). The last two are different facts and must never be reported alike: one is a failure, the other is a scope boundary.","example":"read"},"reason":{"type":"string","description":"Why the read failed. Present if and only if `state` is `could_not_read`. Values name what WE observed, never what the site did wrong — e.g. `access_http_error_status`, `access_transport_error`, `access_body_not_robots`, `access_body_too_large`, `access_response_headers_unavailable`, `access_page_body_unavailable`, `access_fallback_unavailable`.","example":"access_http_error_status"},"httpStatusCode":{"type":"number","description":"The HTTP status observed, where one was. Recorded on success too — the difference between a 404 and a 503 is the fact this whole envelope exists to keep.","example":200}},"required":["state"]},"AiAccessSourcesReadResponse":{"type":"object","properties":{"robotsTxt":{"description":"robots.txt, which decides whether a crawler may REACH the site. Site-wide.","allOf":[{"$ref":"#/components/schemas/AiAccessSurfaceReadResponse"}]},"pageMetaRobotsTag":{"description":"`<meta name=\"robots\">` on the fetched page, which decides whether what was fetched may be USED. PER PAGE — we read the homepage, so a `not_attempted` here means no page-level directive was consulted for any verdict below.","allOf":[{"$ref":"#/components/schemas/AiAccessSurfaceReadResponse"}]},"httpResponseHeaders":{"description":"`X-Robots-Tag` on the HTTP response — the same controls, delivered in headers. Also per page. Microsoft names Copilot in NOARCHIVE and Google names AI Overviews in nosnippet, so when this and `pageMetaRobotsTag` are `not_attempted`, verdicts for those two assistants rest on robots.txt alone.","allOf":[{"$ref":"#/components/schemas/AiAccessSurfaceReadResponse"}]}},"required":["robotsTxt","pageMetaRobotsTag","httpResponseHeaders"]},"AiAccessExplanationResponse":{"type":"object","properties":{"code":{"type":"string","description":"Stable identifier, so a consumer can branch without parsing prose.","example":"training_blocked"},"text":{"type":"string","description":"The sentence. Render it verbatim; do not paraphrase or substitute your own.","example":"Blocking a training crawler asks these operators not to use your content for model training. It does not affect whether AI assistants can cite you."}},"required":["code","text"]},"AiAccessMeasurementResponse":{"type":"object","properties":{"status":{"type":"string","description":"`measured`, `measured_no_policy_found` (a real 404 — the site publishes no robots.txt, which under the standard allows every crawler), or `could_not_measure`. Check this before reading anything else: on `could_not_measure` there are no verdicts at all, and their absence is not openness.","example":"measured"},"measuredAt":{"type":"string","description":"When this was ATTEMPTED — which is when it was measured on any status other than `could_not_measure`, and on that one is the time we tried and failed rather than the time of a measurement. A single response carries one reading per domain and no history, so it supports 'where do we stand' and not 'what changed'; a change question needs two responses taken apart.","example":"2026-08-13T04:12:00.000Z"},"couldNotMeasureReason":{"type":"string","description":"Why the read failed. Present only when `status` is `could_not_measure`.","example":"access_http_error_status"},"sourcesRead":{"description":"Which of the three control surfaces were actually read, each with its own outcome. Read this before quoting any verdict as complete: a `not_attempted` here is a limit of the check, not a property of the site.","allOf":[{"$ref":"#/components/schemas/AiAccessSourcesReadResponse"}]},"explanations":{"description":"Sentences this product is willing to say about the measurement, each with a stable `code` and the `text` to render verbatim. ABSENT when there is nothing that needs saying. It is never an empty array: emptiness would assert that we weighed what needed saying and concluded nothing did.","type":"array","items":{"$ref":"#/components/schemas/AiAccessExplanationResponse"}}},"required":["status","measuredAt","sourcesRead"]},"DecidingCrawlerResponse":{"type":"object","properties":{"userAgentToken":{"type":"string","description":"The crawler's user-agent token exactly as its operator publishes it.","example":"OAI-SearchBot"},"crawlerPurpose":{"type":"string","description":"What blocking this crawler costs. `fetches_pages_to_cite_in_answers` costs visibility; `collects_training_data` costs none and is a content decision; `builds_search_index_that_grounds_assistants` costs visibility indirectly, through the assistant built on that index; `seo_or_backlink_analysis`, `renders_link_previews` and `checks_ad_landing_pages` have no AI consequence. `policy_token_does_not_crawl` names a token that issues no HTTP requests of its own — it is a CONTROL the operator applies to content fetched by a different crawler, and blocking it still changes what an assistant may do, which is why such a token can decide a `cannot_reach_site`. `Google-Extended` is the case to understand: Google documents it as governing both model training and grounding in Gemini Apps, so one directive carries a training consequence and a visibility consequence at once. Read this field before treating any block as costly or costless.","example":"fetches_pages_to_cite_in_answers"},"honoursRobotsTxt":{"type":"string","description":"Whether a rule against this token actually binds. `yes` it binds; `no` it does not; `not_for_user_initiated_requests` and `not_for_security_or_integrity_checks` mean the operator publishes a carve-out for that case, so a rule is written but not guaranteed; `unknown` means nobody publishes either way. ⚠️ WHAT 'BINDS' MEANS DEPENDS ON THE NEIGHBOURING `crawlerPurpose`. For a crawler that fetches, it is whether the crawler obeys the directive. For `policy_token_does_not_crawl` it cannot be — such a token issues no requests — and there it means the OPERATOR honours the directive when deciding what to do with content another crawler already fetched. Both are 'the rule has effect'; only the mechanism differs. ⚠️ THIS FIELD IS NOT ON ITS OWN A STATEMENT OF OBSERVED BEHAVIOUR. It carries the operator's published position OR a measured result, and `honoursRobotsTxtEvidence` is what tells you which. Never quote this value without reading that one.","example":"yes"},"honoursRobotsTxtEvidence":{"type":"string","description":"How well we know the value beside it, and it qualifies that value rather than merely annotating it. `operator_documented` is the operator's own words. `independently_measured` cites a study, so the value is an observation. `disputed` means a credible allegation the operator denies — a `yes` under `disputed` is the operator's CLAIM and contested, so word it as a claim and never as 'this crawler respects robots.txt'. `undocumented` means nobody publishes either way; never read it as 'ignores'.","example":"operator_documented"},"decidedByRuleFor":{"type":"string","description":"WHICH KIND OF RULE decided this crawler, which is what says where to edit. `names_this_crawler`: a group naming it decided the outcome — somebody chose that. `wildcard_catch_all`: no group names it and the `User-agent: *` rule decided it, frequently a rule written before the crawler existed and applying to it by accident. A named group REPLACES the wildcard group rather than adding to it (RFC 9309 §2.2.1), so naming a crawler lifts it out of the catch-all entirely. ABSENT when nothing restricted this crawler — there is no audience for a rule that does not exist.","enum":["names_this_crawler","wildcard_catch_all"],"example":"wildcard_catch_all"},"decidedByDirective":{"type":"string","description":"The directive that decided it, verbatim, so the claim can be checked against the site's own file rather than taken on trust.","example":"Disallow: /"},"decidedByLineNumber":{"type":"number","description":"1-based line number of that directive.","example":14}},"required":["userAgentToken","crawlerPurpose","honoursRobotsTxt","honoursRobotsTxtEvidence"]},"AssistantAccessResponse":{"type":"object","properties":{"assistantName":{"type":"string","description":"The assistant, as its users know it.","example":"ChatGPT"},"crawlerAccessStatus":{"type":"string","description":"Whether this assistant can obtain this site's content: `can_reach_site`, `cannot_reach_site`, `can_reach_part_of_site`, or `blocked_but_may_not_be_honoured` — the last meaning a rule is written against crawlers whose operators publish an exemption, so protection is not guaranteed. Count the assistants in this array to get totals; no count is stored. IT IS NOT A CITATION MEASUREMENT: it says an assistant is permitted to obtain the content, never that it mentions or cites this site. Note also that a `cannot_reach_site` may be decided by a crawler whose `crawlerPurpose` is `policy_token_does_not_crawl` — that is consistent, not contradictory: see that field.","example":"can_reach_site"},"appliesTo":{"type":"string","description":"`whole_site` or `homepage_only`. robots.txt is site-wide; `nosnippet` and NOARCHIVE are per page and we read the homepage, so a verdict leaning on them describes that page alone.","example":"whole_site"},"decidedByCrawlers":{"description":"The crawlers that decided this verdict — the names behind the status. Never publish the verdict without them: 'blocked' is a claim, 'blocked because these two tokens are disallowed' is a finding.","type":"array","items":{"$ref":"#/components/schemas/DecidingCrawlerResponse"}},"explanations":{"description":"Sentences to render verbatim about THIS assistant. ABSENT when there is nothing to say — never an empty array, which would claim we considered what needed saying and found nothing.","type":"array","items":{"$ref":"#/components/schemas/AiAccessExplanationResponse"}}},"required":["assistantName","crawlerAccessStatus","appliesTo","decidedByCrawlers"]},"ModelTrainingAccessResponse":{"type":"object","properties":{"modelOperatorName":{"type":"string","description":"The operator whose training use is governed. Usually the company whose models are affected; `Common Crawl` is the exception — it trains nothing, but publishes the open corpus much training is built from, so a site owner blocking it is making this same decision.","example":"OpenAI"},"trainingAccessStatus":{"type":"string","description":"`may_use_content_for_training`, `may_not_use_content_for_training`, `may_use_part_of_content_for_training`, or `blocked_but_may_not_be_honoured` — the last meaning a rule is written but the deciding crawler is not reliably bound by robots.txt, either because its operator publishes an exemption or because it is measured not to obey the file at all. Read `decidedByCrawlers[].honoursRobotsTxt` and `honoursRobotsTxtEvidence` to tell those two apart; they are different facts and only the second means the content should be treated as taken. AS A RULE, blocking training crawlers costs no AI visibility and is a legitimate content decision — do not report it as a problem, a gap, or something to undo. THE RULE HAS ONE EXCEPTION AND YOU MUST CHECK FOR IT: a few tokens govern training AND an assistant at once, `Google-Extended` being the documented case. Where the same crawler in `decidedByCrawlers` also appears under an entry in `assistantAccess`, that block DOES carry a visibility consequence and the exception applies — report it. The check is mechanical: match on `userAgentToken`. Never suppress a finding on the strength of the general rule without running it.","example":"may_use_content_for_training"},"decidedByCrawlers":{"description":"The crawlers that decided this verdict. Match their `userAgentToken` against the tokens under `assistantAccess[].decidedByCrawlers` to find the dual-purpose case the status description warns about.","type":"array","items":{"$ref":"#/components/schemas/DecidingCrawlerResponse"}},"explanations":{"description":"Sentences to render verbatim about THIS operator. ABSENT when there is nothing to say — never an empty array, which would claim we considered what needed saying and found nothing.","type":"array","items":{"$ref":"#/components/schemas/AiAccessExplanationResponse"}}},"required":["modelOperatorName","trainingAccessStatus","decidedByCrawlers"]},"RuleThatMayNotWorkResponse":{"type":"object","properties":{"userAgentInFile":{"type":"string","description":"The user agent exactly as the site wrote it, so they can find the line.","example":"ChatGPT-User"},"directiveInFile":{"type":"string","description":"The directive as written.","example":"Disallow: /"},"whyItMayNotWork":{"type":"string","description":"Why the rule may not have effect. `operator_exempts_user_initiated_requests`: the operator publishes that robots.txt may not apply when a person asks for the page directly, so the rule is a request rather than protection. `crawler_does_not_honour_robots_txt`: a published study observed it fetching pages after being disallowed. Both point at the crawler's operator. A third cause — a token we do not recognise — is deliberately NOT reported per rule; see `additionalObservations` for our coverage, stated once.","enum":["operator_exempts_user_initiated_requests","crawler_does_not_honour_robots_txt"],"example":"operator_exempts_user_initiated_requests"},"explanation":{"description":"The sentence to render verbatim, naming the operator and citing evidence.","allOf":[{"$ref":"#/components/schemas/AiAccessExplanationResponse"}]}},"required":["userAgentInFile","directiveInFile","whyItMayNotWork","explanation"]},"RuleWithUnintendedScopeResponse":{"type":"object","properties":{"userAgentInFile":{"type":"string","description":"The user agent exactly as the site wrote it, so they can find the group.","example":"GPTBot"},"kind":{"type":"string","description":"`escapes_restrictions_others_get` — the only kind emitted. The site's `User-agent: *` group disallows these paths, and naming this crawler in a group of its own REPLACES that group for it rather than adding to it, so the crawler is permitted where the rest of the web is not.","enum":["escapes_restrictions_others_get"],"example":"escapes_restrictions_others_get"},"affectedRules":{"description":"The wildcard-group directives this crawler is exempt from, verbatim as written in the file. A FLOOR, not the complete list: only directives closing a content area are reported, so ordinary hygiene paths a crawler also escapes — `/wp-admin/`, `/cgi-bin/`, `/feed/` — are deliberately omitted as findings that cost the reader nothing. Belongs to THIS crawler alone; two crawlers in one file can escape different subsets, so never quote one finding's list against another's name.","example":["Disallow: /use-cases/fintech","Disallow: /events/*"],"type":"array","items":{"type":"string"}},"explanation":{"description":"The sentence to render verbatim. It states the mechanism and stops: whether the exemption was intended is not something we can measure, and privileging AI crawlers over other traffic is a legitimate strategy. Do not report this as an error.","allOf":[{"$ref":"#/components/schemas/AiAccessExplanationResponse"}]}},"required":["userAgentInFile","kind","affectedRules","explanation"]},"AiAccessResponse":{"type":"object","properties":{"measurement":{"description":"Whether we measured it, and why not when we didn't.","allOf":[{"$ref":"#/components/schemas/AiAccessMeasurementResponse"}]},"assistantAccess":{"description":"One verdict per AI assistant we track. ABSENT when the policy could not be measured — an empty array would claim we evaluated every assistant and none can reach the site, which is a different fact entirely.","type":"array","items":{"$ref":"#/components/schemas/AssistantAccessResponse"}},"modelTrainingAccess":{"description":"One verdict per model operator. Absent for the same reason as `assistantAccess`.","type":"array","items":{"$ref":"#/components/schemas/ModelTrainingAccessResponse"}},"rulesThatMayNotWork":{"description":"Rules in this site's robots.txt that may not have the effect they appear to have — each attributable to the CRAWLER'S OPERATOR, never to the site. Two causes: the operator publishes a carve-out (commonly for fetches a person asked for directly), or a published study measured the crawler ignoring disallow directives. An empty array means we looked and found none; absent means we did not look. It NEVER suggests replacing a user-agent token: distinguishing a retired token from one we never catalogued needs a registry of live user agents that nobody publishes, and guessing would have told sites their `Omgili` rule was dead in favour of its own predecessor.","type":"array","items":{"$ref":"#/components/schemas/RuleThatMayNotWorkResponse"}},"rulesWithUnintendedScope":{"description":"Rules that worked exactly as written and did NOT reach the crawler beside them — the counterpart to `rulesThatMayNotWork`. Under the robots exclusion standard a group naming a crawler REPLACES the wildcard group for it, so writing `User-agent: GPTBot` / `Allow: /` beneath a `*` group that closes three sections leaves GPTBot the only agent permitted into those sections. Same absent-versus-empty rule as the array above: `[]` means we looked and the file has none.","type":"array","items":{"$ref":"#/components/schemas/RuleWithUnintendedScopeResponse"}},"additionalObservations":{"description":"Facts about the file worth stating even where they change no verdict. Currently one: how much of THIS file our catalog can speak to, e.g. 'names 34 user agents besides the wildcard group, and our catalog covers 24 of them'. That is our coverage gap disclosed with the universe it is drawn from — it is never a claim that the tokens we do not carry are wrong or obsolete.","type":"array","items":{"$ref":"#/components/schemas/AiAccessExplanationResponse"}},"crawlerCatalogVersion":{"type":"string","description":"Which edition of our crawler and assistant tables produced these verdicts, so a stored result stays explainable after the tables change.","example":"2026-08-13"}},"required":["measurement","crawlerCatalogVersion"]},"DnsLookupUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false for this discriminator.","example":false},"reason":{"type":"string","description":"Why this provider is unknown: the DNS query for it never resolved this run (timeout, refused, or the resolver was unreachable), so nothing was measured. Distinct from a domain that publishes no such records, or publishes records we don't attribute to a provider we track — both of those are measured results and carry no discriminator.","enum":["dns_lookup_failed"],"example":"dns_lookup_failed"}},"required":["available","reason"]},"DnsInfrastructureResponse":{"type":"object","properties":{"emailProvider":{"type":"string","description":"Email service provider read from the domain's MX records, or null when the MX lookup didn't resolve this run (see `emailProviderAvailable`). 'Unknown' with no marker beside it is a MEASURED result and means one of two real things: the domain publishes no MX records, or it publishes records we don't attribute to any provider we track. Either way it is a fact about them — so 'Unknown' may be reported as 'no recognised email provider', while a null may not.","example":"Google Workspace","nullable":true},"dnsProvider":{"type":"string","description":"DNS hosting provider read from the domain's NS records, or null when the NS lookup didn't resolve this run (see `dnsProviderAvailable`). Same reading rule as `emailProvider`: an unmarked 'Unknown' is measured, a null is not. Note this is the authoritative DNS host, not a CDN — a domain fronted by a CDN can still resolve through a different nameserver provider.","example":"Cloudflare","nullable":true},"emailProviderAvailable":{"description":"Honest-degradation discriminator for the MX lookup. Present only when that query never resolved this run (timeout, refused, or the resolver was unreachable). When present, `emailProvider` is null and no email-infrastructure claim may be made about this domain — not 'they use no business email', not 'their provider changed'. Absent on healthy scans, absent when the domain simply publishes no MX records (a measured result), and absent on data recorded before this marker existed, where absence means measured.","allOf":[{"$ref":"#/components/schemas/DnsLookupUnavailableResponse"}]},"dnsProviderAvailable":{"description":"Honest-degradation discriminator for the NS lookup. Same semantics as `emailProviderAvailable`, applied to `dnsProvider`, and independent of it — one lookup can fail while the other succeeds, so check the marker belonging to the field you are about to quote.","allOf":[{"$ref":"#/components/schemas/DnsLookupUnavailableResponse"}]}},"required":["emailProvider","dnsProvider"]},"TechTrustCompetitorResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Competitor domain","example":"competitor.com"},"isOwn":{"type":"boolean","description":"Whether this is the project's own domain","example":false},"securityHeaders":{"description":"Security headers analysis","allOf":[{"$ref":"#/components/schemas/SecurityHeadersResponse"}]},"trustSignals":{"description":"Trust signals and compliance badges","allOf":[{"$ref":"#/components/schemas/TrustSignalsResponse"}]},"technologyStack":{"description":"Detected technology stack","allOf":[{"$ref":"#/components/schemas/TechnologyStackResponse"}]},"robotsTxt":{"description":"Robots.txt analysis","allOf":[{"$ref":"#/components/schemas/RobotsTxtResponse"}]},"aiAccess":{"description":"Which AI assistants can reach this site, and which model operators may train on it. ABSENT when the check carries no AI-access information — read that as not evaluated, never as 'open'. A check that attempted the measurement and failed is NOT absent: it carries this section with `measurement.status` of `could_not_measure`, which is a different fact and is reportable as one.","allOf":[{"$ref":"#/components/schemas/AiAccessResponse"}]},"dnsInfrastructure":{"description":"DNS and email infrastructure","allOf":[{"$ref":"#/components/schemas/DnsInfrastructureResponse"}]}},"required":["domain","isOwn","securityHeaders","trustSignals","technologyStack","robotsTxt","dnsInfrastructure"]},"TechTrustDashboardResponse":{"type":"object","properties":{"lastUpdatedAt":{"type":"string","description":"When this data was last updated (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary with competitive gap analysis","allOf":[{"$ref":"#/components/schemas/TechTrustDashboardSummaryResponse"}]},"competitors":{"description":"Per-competitor tech & trust data","type":"array","items":{"$ref":"#/components/schemas/TechTrustCompetitorResponse"}}},"required":["lastUpdatedAt","summary","competitors"]},"PaginationMeta":{"type":"object","properties":{"page":{"type":"number","description":"Current page number (1-indexed)","example":1},"limit":{"type":"number","description":"Number of items per page","example":20},"total":{"type":"number","description":"Total number of items across all pages","example":47},"totalPages":{"type":"number","description":"Total number of pages","example":3},"hasMore":{"type":"boolean","description":"Whether more pages exist after the current page","example":true}},"required":["page","limit","total","totalPages","hasMore"]},"TechTrustHistoryItemResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Run ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this run completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary statistics","allOf":[{"$ref":"#/components/schemas/TechTrustDashboardSummaryResponse"}]}},"required":["runId","completedAt","summary"]},"TechTrustRunDetailResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Run ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this run completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary statistics","allOf":[{"$ref":"#/components/schemas/TechTrustDashboardSummaryResponse"}]},"competitors":{"description":"Per-competitor tech & trust data for this run","type":"array","items":{"$ref":"#/components/schemas/TechTrustCompetitorResponse"}}},"required":["runId","completedAt","summary","competitors"]},"ContentUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false — present only when your own content could not be analyzed","example":false},"reason":{"type":"string","description":"Why your own content is unmeasured this run. 'content_fetch_failed' = your sitemap couldn't be read; 'content_url_not_configured' = no working sitemap could be found for your own domain — none was discovered, or the ones we hold no longer resolve. It describes what we could not find, not something you failed to set up: sitemap locations are re-discovered periodically, so a site that reorganises its sitemaps reads this way until the next re-discovery.","example":"content_fetch_failed","enum":["content_fetch_failed","content_url_not_configured"]}},"required":["available","reason"]},"ContentSummaryCustomerResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Your domain. Unlike every other field here it is not nulled when nothing was measured, because it stays a known fact — it comes from your project's own configuration, not from the sitemap check. The one exception is a project with no own-domain entry on file at all, where there is no domain to report and this carries the literal placeholder `unknown`. That string is a marker, never a domain: don't render it, resolve it, or describe it as a site.","example":"yoursite.com"},"totalUrls":{"type":"number","description":"Total URLs discovered across all your sitemaps, or null when your own sitemap couldn't be analyzed this run. A `0` is a MEASURED finding — a sitemap we read that lists nothing — so never read a null as 0.","example":350,"nullable":true},"strategicUrls":{"type":"number","description":"Strategic URLs — the sum over the 9 strategic categories ONLY (Blog Posts, Documentation, Free Tools, Landing Pages, Case Studies, Comparison Pages, Integrations, Changelog, Webinars). Legal, Programmatic Pages, Other are counted in `categorizedCounts` and excluded here, so `totalUrls - strategicUrls` is NOT \"legal plus junk\": on a vendor that generates templated pages at scale, most of that difference is Programmatic Pages — a real content operation — and attributing it to noise understates them badly. Read `categorizedCounts.programmatic` before drawing any conclusion from the difference. Null when your own sitemap couldn't be analyzed this run. A `0` is a measured finding and the honest floor of the content comparison; a null means we never read the sitemap.","example":280,"nullable":true},"categorizedCounts":{"type":"object","description":"URL counts per content category, or null when your own sitemap couldn't be analyzed this run. Nulled as a whole rather than returned all-zero, because an all-zero map reads as \"measured, and every category is empty\" — a much stronger claim than \"we couldn't look\". The keys are the 12 categories we classify into: blog, docs, tools, landing, caseStudies, comparison, integrations, changelog, webinars, legal, programmatic, other. A category absent from the map has zero URLs; a category present with 0 was measured as empty. Note this map is WIDER than the set the gap analysis evaluates: Legal, Programmatic Pages, Other are counted here but never assessed for gaps, advantages or on-track status, so a category with a count here and no entry in `criticalGaps` / `advantages` / `onTrack` may simply never have been a candidate. `strategicUrls` is the sum over the 9 evaluated ones, not over this whole map.","nullable":true,"additionalProperties":{"type":"number"},"example":{"blog":120,"docs":80,"tools":5,"landing":30,"legal":10,"caseStudies":0,"comparison":3,"integrations":25,"changelog":15,"webinars":2,"other":60}},"contentAnalysisAvailable":{"description":"Present ONLY when your own sitemap couldn't be analyzed this run (fetch failed, or no working sitemap could be found) — see `reason`. When present, every metric in this object is null, `strategicUrlGap` is null, and all four gap lists (`criticalGaps`, `significantGaps`, `advantages`, `onTrack`) and their four count siblings are null. Nothing here is a measured fact; the nulls say so on their own, and this field records why. Only `domain` remains populated — and where no domain was on file it holds the literal placeholder `unknown` rather than being omitted, so treat that exact string as 'no domain', not as a site name. This flag is a sufficient reason for those eight nulls but NOT a necessary one: they are also null when your own sitemap read fine and no competitor returned usable data (`comparableCompetitors` is 0), with this flag absent — so never conclude from a null gap list alone that YOUR check failed. Note too the difference between a null list and an empty one: `[]` means we compared and found none, which is a real finding.","allOf":[{"$ref":"#/components/schemas/ContentUnavailableResponse"}]}},"required":["domain","totalUrls","strategicUrls","categorizedCounts"]},"ContentSummaryTopCompetitorResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Top competitor domain by strategic URLs","example":"rival.com"},"strategicUrls":{"type":"number","description":"Their strategic URL count. A 0 here is a measurement, not a missing value: every competitor we read was read successfully and none publishes a strategic page. Report it as a leader with zero — usually the customer's largest lead — never as 'no data' or 'no competitor'.","example":420}},"required":["domain","strategicUrls"]},"ContentGapLeaderResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Leader competitor domain","example":"competitor.com"},"count":{"type":"number","description":"Number of URLs in this category","example":42},"exampleUrls":{"description":"Example URLs for inspiration (3-5)","example":["https://competitor.com/blog/post-1","https://competitor.com/blog/post-2"],"type":"array","items":{"type":"string"}}},"required":["domain","count","exampleUrls"]},"ContentCriticalGapResponse":{"type":"object","properties":{"category":{"type":"string","description":"Content category where you have zero content","example":"caseStudies"},"categoryLabel":{"type":"string","description":"Human-readable category name","example":"Case Studies"},"competitorsWithContent":{"type":"number","description":"Number of competitors that have content in this category","example":3},"leader":{"description":"Competitor leading in this category","allOf":[{"$ref":"#/components/schemas/ContentGapLeaderResponse"}]},"actionHint":{"type":"string","description":"Actionable guidance for addressing this gap","example":"Create 3-5 case studies showcasing customer success stories"}},"required":["category","categoryLabel","competitorsWithContent","leader"]},"ContentSignificantGapResponse":{"type":"object","properties":{"category":{"type":"string","description":"Content category where you are significantly behind","example":"blog"},"categoryLabel":{"type":"string","description":"Human-readable category name","example":"Blog Posts"},"customerCount":{"type":"number","description":"Your URL count in this category","example":5},"competitorAverage":{"type":"number","description":"Average competitor URL count","example":25},"competitorMax":{"type":"number","description":"Maximum competitor URL count","example":42},"gapPercentage":{"type":"number","description":"How far behind the competitor average you are in this category, as a percentage of that average. A MAGNITUDE, always POSITIVE: 80 means you publish 80% fewer URLs here than the average competitor, and a larger number is a wider gap. It cannot be negative or small — an entry appears in this list only where your count is under half the competitor average, so this figure is always above 50. Being AHEAD in a category produces no entry here at all rather than a negative number; look to `advantages` for that. NOTE THE OPPOSITE CONVENTION on `strategicUrlGap` elsewhere in this response, which is a signed difference where negative means behind. These two gap fields do not share a sign rule, so read each one's own description and never carry a direction from one to the other.","example":80},"leader":{"description":"Competitor leading in this category","allOf":[{"$ref":"#/components/schemas/ContentGapLeaderResponse"}]},"actionHint":{"type":"string","description":"Actionable guidance for addressing this gap","example":"Increase blog output to at least match competitor average of 25 posts"}},"required":["category","categoryLabel","customerCount","competitorAverage","competitorMax","gapPercentage","leader"]},"ContentAdvantageResponse":{"type":"object","properties":{"category":{"type":"string","description":"Content category where you lead","example":"docs"},"categoryLabel":{"type":"string","description":"Human-readable category name","example":"Documentation"},"customerCount":{"type":"number","description":"Your URL count in this category","example":120},"competitorMax":{"type":"number","description":"Maximum competitor URL count","example":45},"leadAmount":{"type":"number","description":"How many more URLs you have than the best competitor","example":75}},"required":["category","categoryLabel","customerCount","competitorMax","leadAmount"]},"ContentOnTrackResponse":{"type":"object","properties":{"category":{"type":"string","description":"Content category where you are competitive","example":"landing"},"categoryLabel":{"type":"string","description":"Human-readable category name","example":"Landing Pages"},"customerCount":{"type":"number","description":"Your URL count in this category","example":30},"competitorMax":{"type":"number","description":"Maximum competitor URL count","example":35},"differencePercent":{"type":"number","description":"Percentage difference from the best competitor","example":-14}},"required":["category","categoryLabel","customerCount","competitorMax","differencePercent"]},"ContentDashboardSummaryResponse":{"type":"object","properties":{"sitemapsChecked":{"type":"number","description":"Number of sitemaps checked in this run","example":8},"sitemapsSuccessful":{"type":"number","description":"Number of sitemaps successfully processed","example":7},"customer":{"description":"Your domain's content metrics","allOf":[{"$ref":"#/components/schemas/ContentSummaryCustomerResponse"}]},"topCompetitor":{"description":"Competitor with the most strategic URLs, or null when no competitor returned usable content data this run — that is the only thing null means here, and it never means the competitors have none. A competitor leading with `strategicUrls: 0` is the opposite case and is a real result: everyone was measured and nobody publishes a strategic page.","nullable":true,"allOf":[{"$ref":"#/components/schemas/ContentSummaryTopCompetitorResponse"}]},"strategicUrlGap":{"type":"number","description":"Your strategic URL count minus the top competitor's (negative = you are behind), or null when there is no comparison to make: your own sitemap was unmeasured, or no competitor returned usable content data. A 0 means genuinely level. Was a forced 0 before, which read as 'level with them' either way. A gap equal to your own count is what a measured field of zeros produces — your largest possible lead, and a finding to report rather than an anomaly to explain away.","example":-140,"nullable":true},"overallRank":{"type":"number","description":"Your rank by strategic content among the sites that returned comparable content data — your own site plus `comparableCompetitors` competitors (field size = comparableCompetitors + 1). Null when fewer than two competitors had usable data, or when your own site returned no usable data to rank.","example":3,"nullable":true},"comparableCompetitors":{"type":"number","description":"Competitors (excluding your own site) that returned usable content data — the sample size behind the gap and advantage comparisons. The overall rank is taken over this set plus your own site, so the rank's field size is `comparableCompetitors + 1`.","example":4},"criticalGaps":{"description":"Content categories where you have zero content but competitors do. Only the 9 strategic categories are evaluated here: Blog Posts, Documentation, Free Tools, Landing Pages, Case Studies, Comparison Pages, Integrations, Changelog, Webinars. Legal, Programmatic Pages, Other are counted in `categorizedCounts` but never assessed, so their absence from this list is not a verdict about them. Null in TWO cases, and they are different facts you must not report as the same one: your own sitemap couldn't be analyzed this run (`customer.contentAnalysisAvailable` is present — our reach into YOUR site), or no competitor returned usable content data (`comparableCompetitors` is 0 — our reach into THEIRS, with your own site measured fine). Check both before saying whose check failed. An empty array is neither: it means we compared and found none, which is a real finding.","nullable":true,"type":"array","items":{"$ref":"#/components/schemas/ContentCriticalGapResponse"}},"significantGaps":{"description":"Content categories where you are significantly behind (>50%). Only the 9 strategic categories are evaluated here: Blog Posts, Documentation, Free Tools, Landing Pages, Case Studies, Comparison Pages, Integrations, Changelog, Webinars. Legal, Programmatic Pages, Other are counted in `categorizedCounts` but never assessed, so their absence from this list is not a verdict about them. Null in TWO cases, and they are different facts you must not report as the same one: your own sitemap couldn't be analyzed this run (`customer.contentAnalysisAvailable` is present — our reach into YOUR site), or no competitor returned usable content data (`comparableCompetitors` is 0 — our reach into THEIRS, with your own site measured fine). Check both before saying whose check failed. An empty array is neither: it means we compared and found none, which is a real finding.","nullable":true,"type":"array","items":{"$ref":"#/components/schemas/ContentSignificantGapResponse"}},"advantages":{"description":"Content categories where you lead all competitors. Only the 9 strategic categories are evaluated here: Blog Posts, Documentation, Free Tools, Landing Pages, Case Studies, Comparison Pages, Integrations, Changelog, Webinars. Legal, Programmatic Pages, Other are counted in `categorizedCounts` but never assessed, so their absence from this list is not a verdict about them. A category missing from this list either wasn't evaluated or wasn't led — check it against that set before reporting either. Null in TWO cases, and they are different facts you must not report as the same one: your own sitemap couldn't be analyzed this run (`customer.contentAnalysisAvailable` is present — our reach into YOUR site), or no competitor returned usable content data (`comparableCompetitors` is 0 — our reach into THEIRS, with your own site measured fine). Check both before saying whose check failed. An empty array is neither: it means we compared and found none, which is a real finding. A null here in particular must NOT be read as leading in none.","nullable":true,"type":"array","items":{"$ref":"#/components/schemas/ContentAdvantageResponse"}},"onTrack":{"description":"Content categories where you are competitive (within threshold). Only the 9 strategic categories are evaluated here: Blog Posts, Documentation, Free Tools, Landing Pages, Case Studies, Comparison Pages, Integrations, Changelog, Webinars. Legal, Programmatic Pages, Other are counted in `categorizedCounts` but never assessed, so their absence from this list is not a verdict about them. Null in TWO cases, and they are different facts you must not report as the same one: your own sitemap couldn't be analyzed this run (`customer.contentAnalysisAvailable` is present — our reach into YOUR site), or no competitor returned usable content data (`comparableCompetitors` is 0 — our reach into THEIRS, with your own site measured fine). Check both before saying whose check failed. An empty array is neither: it means we compared and found none, which is a real finding.","nullable":true,"type":"array","items":{"$ref":"#/components/schemas/ContentOnTrackResponse"}},"totalCriticalGaps":{"type":"number","description":"Total number of critical content gaps, out of the 9 strategic categories evaluated. Derived from the list above, so the two are always consistent — including when both are null. Null in TWO cases, and they are different facts you must not report as the same one: your own sitemap couldn't be analyzed this run (`customer.contentAnalysisAvailable` is present — our reach into YOUR site), or no competitor returned usable content data (`comparableCompetitors` is 0 — our reach into THEIRS, with your own site measured fine). Check both before saying whose check failed. An empty array is neither: it means we compared and found none, which is a real finding.","example":2,"nullable":true},"totalSignificantGaps":{"type":"number","description":"Total number of significant content gaps, out of the 9 strategic categories evaluated. Derived from the list above, so the two are always consistent. Null in TWO cases, and they are different facts you must not report as the same one: your own sitemap couldn't be analyzed this run (`customer.contentAnalysisAvailable` is present — our reach into YOUR site), or no competitor returned usable content data (`comparableCompetitors` is 0 — our reach into THEIRS, with your own site measured fine). Check both before saying whose check failed. An empty array is neither: it means we compared and found none, which is a real finding.","example":1,"nullable":true},"totalAdvantages":{"type":"number","description":"Total number of content advantages, out of the 9 strategic categories evaluated. Derived from the list above, so the two are always consistent. Null in TWO cases, and they are different facts you must not report as the same one: your own sitemap couldn't be analyzed this run (`customer.contentAnalysisAvailable` is present — our reach into YOUR site), or no competitor returned usable content data (`comparableCompetitors` is 0 — our reach into THEIRS, with your own site measured fine). Check both before saying whose check failed. An empty array is neither: it means we compared and found none, which is a real finding.","example":3,"nullable":true},"totalOnTrack":{"type":"number","description":"Total number of on-track categories, out of the 9 strategic categories evaluated. Derived from the list above, so the two are always consistent. Null in TWO cases, and they are different facts you must not report as the same one: your own sitemap couldn't be analyzed this run (`customer.contentAnalysisAvailable` is present — our reach into YOUR site), or no competitor returned usable content data (`comparableCompetitors` is 0 — our reach into THEIRS, with your own site measured fine). Check both before saying whose check failed. An empty array is neither: it means we compared and found none, which is a real finding.","example":4,"nullable":true}},"required":["sitemapsChecked","sitemapsSuccessful","customer","strategicUrlGap","comparableCompetitors","criticalGaps","significantGaps","advantages","onTrack","totalCriticalGaps","totalSignificantGaps","totalAdvantages","totalOnTrack"]},"ContentCompetitorUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false — present only when this competitor row carries no content data","example":false},"reason":{"type":"string","description":"Why this competitor's content couldn't be counted this run. 'no_sitemap_published' = no working sitemap was found for them — nothing was discovered, or every location we know of returns 404. Read it as 'nothing findable where we look', not as proof they publish none: we re-discover sitemap locations periodically, so a competitor who has MOVED theirs reads this way until we re-check. What it does support is that their content is currently invisible to sitemap-based analysis, and to sitemap-reading crawlers. 'sitemap_fetch_failed' = our fetch or parse failed this run — nothing was measured, so no verdict about the competitor's content may be derived from this row. On the dashboard this reflects the latest known fetch state — a newer run's in-flight failure can briefly surface here until that run completes; in run-detail responses the reason is scoped to that run.","example":"no_sitemap_published","enum":["no_sitemap_published","sitemap_fetch_failed"]}},"required":["available","reason"]},"ContentCompetitorResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Competitor domain","example":"competitor.com"},"isOwn":{"type":"boolean","description":"Whether this is the project's own domain","example":false},"totalUrls":{"type":"number","description":"Total DISTINCT URLs across this competitor's sitemaps, or null exactly when `contentDataAvailable` is present (no readable sitemap this run). Never read a null as 0: '0 URLs' is a measured claim about a readable sitemap. Distinct matters and is not a formality — two of a competitor's stored sitemap locations can resolve to the same set of pages after they reorganise, and each page is counted once regardless. So this figure need not equal the sum of the per-sitemap counts you may see elsewhere, and `sitemapCount` counts the files they publish rather than the ones that contributed here. Do not reconcile the two; this is the page count.","nullable":true,"example":450},"strategicUrls":{"type":"number","description":"Strategic URLs — the sum over the 9 strategic categories ONLY. Legal, Programmatic Pages, Other are counted in `categorizedCounts` and excluded here, so `totalUrls - strategicUrls` is NOT \"legal plus junk\" — most of it may be Programmatic Pages, which is a content operation rather than noise. Null exactly when `contentDataAvailable` is present.","nullable":true,"example":380},"categorizedCounts":{"type":"object","description":"URL counts by content category, or null if categorization unavailable. Keys are the 12 categories we classify into: blog, docs, tools, landing, caseStudies, comparison, integrations, changelog, webinars, legal, programmatic, other. A category ABSENT from the map was not computed for this run — older runs predate categories added since — while a category present with `0` was measured as empty. Those are different facts; do not report an absent key as a zero.","nullable":true,"additionalProperties":{"type":"number"},"example":{"blog":150,"docs":100,"tools":10,"landing":10,"caseStudies":10,"comparison":10,"integrations":10,"changelog":10,"webinars":10,"legal":10,"programmatic":27996,"other":65}},"programmaticExampleUrls":{"description":"Up to 5 real URLs sampled from this competitor's `programmatic` pages, spread across the sections they came from. Empty when the competitor publishes none.\n\nWhy these ship: the platform reports templated pages WITHOUT judging why a vendor generates them, because a URL shape cannot show intent — for a programmatic-SEO player it is the growth engine, for a reference database it is plumbing. That refusal is only honest if you can settle it yourself, and these URLs do that in seconds. **Open two or three before characterising a large `programmatic` count.** They are evidence for a count, not a measurement of their own — sampled at read time, never stored, so they may differ between calls.\n\n`null` exactly when `contentDataAvailable` is present: we read no sitemap, so there is nothing to sample. An empty ARRAY is the different, measured fact that we read the sitemap and it has no programmatic pages.","nullable":true,"example":["https://competitor.com/ncm/01012100","https://competitor.com/cnae/0119-9-01","https://competitor.com/cfop/1101"],"type":"array","items":{"type":"string"}},"sitemapCount":{"type":"number","description":"Number of READABLE sitemaps this run for this competitor (0 when `contentDataAvailable` is present)","example":2},"contentDataAvailable":{"description":"Honest-degradation discriminator (per-competitor). Present only when NO sitemap could be read for this competitor this run — see `reason` for whether that is a measured absence (no working sitemap could be found for them, a claim whose limits `reason` spells out) or our fetch failure. When present, `totalUrls` and `strategicUrls` are null and `categorizedCounts` is null — do not derive any content verdict (e.g. 'competitor has no blog') from this row. Absent whenever at least one sitemap was read (the normal path). On the dashboard, rows reflect the latest known state per sitemap, so a fetch failure in a newer, still-running run can briefly mark a row unavailable until that run completes; run-detail rows are scoped to their own run.","allOf":[{"$ref":"#/components/schemas/ContentCompetitorUnavailableResponse"}]}},"required":["domain","isOwn","totalUrls","strategicUrls","programmaticExampleUrls","sitemapCount"]},"ContentDashboardResponse":{"type":"object","properties":{"lastUpdatedAt":{"type":"string","description":"When this data was last updated (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary with content gap analysis","allOf":[{"$ref":"#/components/schemas/ContentDashboardSummaryResponse"}]},"competitors":{"description":"Per-competitor content data","type":"array","items":{"$ref":"#/components/schemas/ContentCompetitorResponse"}}},"required":["lastUpdatedAt","summary","competitors"]},"ContentHistoryItemResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Run ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this run completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary statistics","allOf":[{"$ref":"#/components/schemas/ContentDashboardSummaryResponse"}]}},"required":["runId","completedAt","summary"]},"ContentRunDetailResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Run ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this run completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary statistics","allOf":[{"$ref":"#/components/schemas/ContentDashboardSummaryResponse"}]},"competitors":{"description":"Per-competitor content data for this run","type":"array","items":{"$ref":"#/components/schemas/ContentCompetitorResponse"}}},"required":["runId","completedAt","summary","competitors"]},"ContentChangelogItemResponse":{"type":"object","properties":{"competitorDomain":{"type":"string","description":"Competitor domain","example":"competitor.com"},"sitemapUrl":{"type":"string","description":"Sitemap URL where changes were detected","example":"https://competitor.com/sitemap.xml"},"checkedAt":{"type":"string","description":"When this change was detected (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"totalAdded":{"type":"number","description":"Total number of URLs added","example":5},"totalRemoved":{"type":"number","description":"Total number of URLs removed","example":2},"addedByCategory":{"type":"object","description":"Added URLs grouped by content category. Categories are the same ones the content dashboard reports, decided over the competitor's whole sitemap rather than over these URLs alone — so a page added into a templated catalog arrives as `programmatic`, matching what the dashboard says about the same page. By default each category is capped at 3 sample URLs (numeric totals remain accurate via `addedCounts`). Pass `allUrlsPerCategory=true` to return full URL lists.","example":{"blog":["https://competitor.com/blog/new-post"],"docs":["https://competitor.com/docs/new-guide"],"programmatic":["https://competitor.com/ncm/01012100"]}},"removedByCategory":{"type":"object","description":"Removed URLs grouped by content category, decided over the sitemap the URL was removed FROM. By default each category is capped at 3 sample URLs (numeric totals remain accurate via `removedCounts`). Pass `allUrlsPerCategory=true` to return full URL lists.","example":{"landing":["https://competitor.com/old-landing"]}},"addedCounts":{"type":"object","description":"Count of added URLs per category","example":{"blog":1,"docs":1}},"removedCounts":{"type":"object","description":"Count of removed URLs per category","example":{"landing":1}}},"required":["competitorDomain","sitemapUrl","checkedAt","totalAdded","totalRemoved","addedByCategory","removedByCategory","addedCounts","removedCounts"]},"ContentCategory":{"type":"string","enum":["blog","docs","tools","landing","caseStudies","comparison","integrations","changelog","webinars","legal","programmatic","other"]},"PositioningMessagingUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false for this discriminator.","example":false},"reason":{"type":"string","description":"Why your homepage messaging couldn't be scored. 'homepage_fetch_failed' = we couldn't retrieve your homepage this run; 'homepage_url_not_configured' = no homepage URL is set for your own domain.","enum":["homepage_fetch_failed","homepage_url_not_configured"],"example":"homepage_fetch_failed"}},"required":["available","reason"]},"PositioningSummaryCustomerResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Your domain","example":"yoursite.com"},"messagingStrengthScore":{"type":"number","description":"Messaging strength score (0-100) based on homepage completeness. `null` when your own homepage couldn't be analyzed this run (see `messagingAnalysisAvailable`) — do not report a messaging score or you-vs-competitor ranking in that case.","example":72,"nullable":true},"hasSpecificHeadline":{"type":"boolean","description":"Whether your headline includes specific metrics or numbers, or null when your own homepage couldn't be analyzed this run. A measured `false` is REAL — we read the page and the headline is generic — so never report a null as a generic headline.","example":true,"nullable":true},"hasStrongCta":{"type":"boolean","description":"Whether your CTA is strong and specific (not just 'Get Started'), or null when your homepage was unmeasured. A measured `false` is a real finding.","example":true,"nullable":true},"showsPricing":{"type":"boolean","description":"Whether pricing is shown on your homepage, or null when it was unmeasured. A measured `false` is a real finding.","example":false,"nullable":true},"hasFreeTrial":{"type":"boolean","description":"Whether you offer a free trial, or null when your homepage was unmeasured. A measured `false` is a real finding.","example":true,"nullable":true},"primaryCta":{"type":"string","description":"Your primary CTA text, or null when your homepage was unmeasured. Note the distinction: a measured empty string means we read the page and there IS no call to action — a real finding — while null means we never read it.","example":"Start Free Trial","nullable":true},"mainHeadline":{"type":"string","description":"Your main headline, or null when your homepage was unmeasured. A measured empty string is a real finding.","example":"Ship Projects 10x Faster","nullable":true},"messagingAnalysisAvailable":{"description":"Availability flag for your own homepage messaging. Present ONLY when your own homepage couldn't be analyzed this run (fetch failed, or no homepage URL configured) — see `reason`. When present, EVERY metric in this object is null, `messagingScoreGap` is null, and your own cells in the comparison widgets (`ctaComparison.yourCta`, `pricingComparison.youShowPrice`, `pricingComparison.youHaveFreeTrial`) are null too. Nothing here is a measured fact; the nulls say so on their own, and this field records why. Only `domain` remains populated. Absent on healthy runs (the normal path).","allOf":[{"$ref":"#/components/schemas/PositioningMessagingUnavailableResponse"}]}},"required":["domain","messagingStrengthScore","hasSpecificHeadline","hasStrongCta","showsPricing","hasFreeTrial","primaryCta","mainHeadline"]},"PositioningSummaryTopCompetitorResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Top messaging competitor domain","example":"rival.com"},"messagingStrengthScore":{"type":"number","description":"Their messaging strength score (0-100)","example":88},"primaryCta":{"type":"string","description":"Their primary CTA text","example":"Try It Free"},"mainHeadline":{"type":"string","description":"Their main headline","example":"The Future of Work"}},"required":["domain","messagingStrengthScore","primaryCta","mainHeadline"]},"PositioningCtaComparisonResponse":{"type":"object","properties":{"yourCta":{"type":"string","description":"Your primary CTA text, or null when your own homepage couldn't be analyzed this run (see `customer.messagingAnalysisAvailable`) — never report a 'no primary CTA' finding or a your-CTA-vs-competitor comparison off a null. A measured homepage with no CTA is an empty string, not null.","nullable":true,"example":"Start Free Trial"},"strongestCompetitorCta":{"type":"string","description":"Strongest competitor's CTA text","example":"Get Started — It's Free"},"strongestCompetitorDomain":{"type":"string","description":"Domain of the competitor with the strongest CTA","example":"rival.com"}},"required":["yourCta","strongestCompetitorCta","strongestCompetitorDomain"]},"PositioningPricingComparisonResponse":{"type":"object","properties":{"competitorsShowingPrice":{"type":"number","description":"Number of competitors showing pricing on homepage","example":3},"competitorsWithFreeTrial":{"type":"number","description":"Number of competitors offering a free trial","example":4},"youShowPrice":{"type":"boolean","description":"Whether you show pricing on your homepage, or null when your own homepage couldn't be analyzed this run (see `customer.messagingAnalysisAvailable`) — never report a pricing-transparency posture off a null. A measured homepage that genuinely shows no pricing is false, not null.","nullable":true,"example":false},"youHaveFreeTrial":{"type":"boolean","description":"Whether you offer a free trial, or null when your own homepage couldn't be analyzed this run (see `customer.messagingAnalysisAvailable`) — never report a free-trial claim off a null. A measured homepage with genuinely no free trial is false, not null.","nullable":true,"example":true}},"required":["competitorsShowingPrice","competitorsWithFreeTrial","youShowPrice","youHaveFreeTrial"]},"PositioningDashboardSummaryResponse":{"type":"object","properties":{"competitorsChecked":{"type":"number","description":"Number of competitor homepages checked in this run","example":6},"competitorsSuccessful":{"type":"number","description":"Number of competitor homepages successfully analyzed","example":5},"customer":{"description":"Your domain's messaging metrics","allOf":[{"$ref":"#/components/schemas/PositioningSummaryCustomerResponse"}]},"topMessagingCompetitor":{"description":"Competitor with the highest messaging strength score, or null if no competitors checked","nullable":true,"allOf":[{"$ref":"#/components/schemas/PositioningSummaryTopCompetitorResponse"}]},"ctaComparison":{"description":"CTA comparison between you and the strongest competitor, or null if unavailable","nullable":true,"allOf":[{"$ref":"#/components/schemas/PositioningCtaComparisonResponse"}]},"pricingComparison":{"description":"Pricing transparency comparison across competitors","allOf":[{"$ref":"#/components/schemas/PositioningPricingComparisonResponse"}]},"messagingScoreGap":{"type":"number","description":"Your messaging score minus the top competitor's (negative = you are behind), or null when there is no comparison to make: your own homepage was unmeasured, or no competitor was scored. A 0 means genuinely level. Was a forced 0, which read as 'level' either way.","example":-16,"nullable":true}},"required":["competitorsChecked","competitorsSuccessful","customer","pricingComparison","messagingScoreGap"]},"PositioningCompetitorContentResponse":{"type":"object","properties":{"pageTitle":{"type":"string","description":"Page title","example":"Acme - The #1 Project Management Tool"},"pageDescription":{"type":"string","description":"Page meta description","example":"Manage projects, track tasks, and collaborate with your team."},"mainHeadline":{"type":"string","description":"Main homepage headline","example":"Ship Projects 10x Faster"},"tagline":{"type":"string","description":"Tagline or subheadline","example":"The all-in-one platform for modern teams"},"valueProposition":{"type":"string","description":"Core value proposition statement","example":"Streamline your workflow with AI-powered project management"},"primaryCTA":{"type":"string","description":"Primary call-to-action text","example":"Start Free Trial"},"secondaryCTA":{"type":"string","description":"Secondary call-to-action text","example":"Book a Demo"},"keyOfferings":{"description":"Key product offerings listed on homepage","example":["Task Management","Time Tracking","Team Collaboration"],"type":"array","items":{"type":"string"}},"targetAudience":{"type":"string","description":"Target audience as described on homepage","example":"Product teams and agencies"},"mainDifferentiator":{"type":"string","description":"Main differentiator or unique selling point","example":"AI-powered automation that saves 5 hours per week"},"pricingMentioned":{"type":"boolean","description":"Whether pricing is mentioned on the homepage","example":true},"startingPrice":{"type":"string","description":"Starting price if mentioned on homepage","example":"$9/month"},"hasFreeTrial":{"type":"boolean","description":"Whether a free trial is offered","example":true},"freeTrialDuration":{"type":"string","description":"Free trial duration if offered","example":"14 days"}},"required":["pageTitle","pageDescription","mainHeadline","tagline","valueProposition","primaryCTA","secondaryCTA","keyOfferings","targetAudience","mainDifferentiator","pricingMentioned","startingPrice","hasFreeTrial","freeTrialDuration"]},"PositioningCompetitorResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Competitor domain","example":"competitor.com"},"isOwn":{"type":"boolean","description":"Whether this is the project's own domain","example":false},"homepageUrl":{"type":"string","description":"Homepage URL that was analyzed","example":"https://competitor.com"},"content":{"description":"Extracted homepage content and messaging","allOf":[{"$ref":"#/components/schemas/PositioningCompetitorContentResponse"}]}},"required":["domain","isOwn","homepageUrl","content"]},"PositioningDashboardResponse":{"type":"object","properties":{"lastUpdatedAt":{"type":"string","description":"When this data was last updated (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary with messaging gap analysis","allOf":[{"$ref":"#/components/schemas/PositioningDashboardSummaryResponse"}]},"competitors":{"description":"Per-competitor positioning data","type":"array","items":{"$ref":"#/components/schemas/PositioningCompetitorResponse"}}},"required":["lastUpdatedAt","summary","competitors"]},"PositioningHistoryItemResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Run ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this run completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary statistics","allOf":[{"$ref":"#/components/schemas/PositioningDashboardSummaryResponse"}]}},"required":["runId","completedAt","summary"]},"PositioningRunDetailResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Run ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this run completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary statistics","allOf":[{"$ref":"#/components/schemas/PositioningDashboardSummaryResponse"}]},"competitors":{"description":"Per-competitor positioning data for this run","type":"array","items":{"$ref":"#/components/schemas/PositioningCompetitorResponse"}}},"required":["runId","completedAt","summary","competitors"]},"PricingUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false for this discriminator.","example":false},"reason":{"type":"string","description":"Why your own pricing couldn't be analyzed. 'pricing_fetch_failed' = we couldn't retrieve your pricing page this run; 'pricing_url_not_configured' = no pricing URL is set for your own domain.","enum":["pricing_fetch_failed","pricing_url_not_configured"],"example":"pricing_fetch_failed"}},"required":["available","reason"]},"PricingSummaryCustomerResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Your domain. Unlike every other field here it is not nulled when nothing was measured, because it stays a known fact — it is read from your project's own configuration, not from the pricing check. The one exception is a project with no own-domain entry on file at all, where there is no domain to report and this carries the literal placeholder `unknown`. That string is a marker, never a domain: don't render it, resolve it, or describe it as a site.","example":"yoursite.com"},"popularPlanPrice":{"type":"number","description":"The price of the plan named by `popularPlanName` — which is not always the plan you badge as most popular; see that field. Null if unavailable. Check `popularPlanCurrency` before rendering a currency symbol next to it — this amount is in whatever currency your pricing page uses, not necessarily USD — and `popularPlanUnit` before describing it, since it may be priced per seat rather than flat.","example":49,"nullable":true},"popularPlanCurrency":{"type":"string","description":"Currency of `popularPlanPrice` as read off your pricing page — 'USD' / 'EUR' / 'GBP', or the verbatim currency symbol for other currencies. Null when no currency could be detected on the page, or on data recorded before currency capture existed — never treat null as USD.","example":"USD","nullable":true},"popularPlanUnit":{"type":"string","description":"The licensed unit your popular plan price is quoted in — 'flat' for a plain monthly amount, or 'per-seat' / 'per-license' / similar for per-unit monthly licensing. Null when your price isn't a comparable monthly amount at all (usage-based, one-time, or unreadable), and absent on data recorded before unit capture existed — never treat either as 'flat'. Compare it with `marketPricingUnit`: when the two differ, `pricePositionPercent` is null because a per-seat price and a flat price are not the same kind of number, however similar the figures look.","example":"flat","nullable":true},"popularPlanName":{"type":"string","description":"The plan we use to represent your pricing: your own 'most popular' badge when that plan lists a comparable monthly price, otherwise the cheapest plan on your page that does — a free or quote-only flagship carries no amount to position against a market. So this is not always the plan you feature. `popularPlanPrice`, `popularPlanCurrency` and `popularPlanUnit` all describe THIS plan, whichever one it is. Null if your pricing wasn't measured this run.","example":"Professional","nullable":true},"pricingModel":{"type":"string","description":"Your pricing model type, or null if unavailable","example":"per-seat","nullable":true},"hasFreePlan":{"type":"boolean","description":"Whether you offer a permanent free plan, or null if your own pricing wasn't measured this run. `false` is a measured finding — we read your pricing page and there is no free plan. Never report a null as 'no free plan'.","example":false,"nullable":true},"hasFreeTrial":{"type":"boolean","description":"Whether you offer a free trial, or null if your own pricing wasn't measured this run. `false` is a measured finding.","example":true,"nullable":true},"freeTrialDuration":{"type":"string","description":"Free trial duration, or null if not offered or not measured","example":"14 days","nullable":true},"hasEnterprisePricing":{"type":"boolean","description":"Whether you offer enterprise/custom pricing, or null if your own pricing wasn't measured this run. `false` is a measured finding.","example":true,"nullable":true},"tierCount":{"type":"number","description":"Number of pricing tiers, or null if your own pricing wasn't measured this run. Never read a null as 0 — '0 tiers' is a claim about your pricing page, not about whether we reached it.","example":3,"nullable":true},"pricingAnalysisAvailable":{"description":"Availability flag for your own pricing. Present ONLY when your own pricing couldn't be analyzed this run (fetch failed, or no pricing URL configured) — see `reason`. When present, every pricing metric in this object is `null`, and so are `pricePositionPercent` and all three gap flags (`hasPriceGap`, `hasFreeTierGap`, `hasEnterpriseGap`). Nothing here is a measured fact: report no price, market position, tier status, tier count or gap verdict. Only `domain` remains populated — and where no domain was on file it holds the literal placeholder `unknown` rather than being omitted, so treat that exact string as 'no domain', not as a site name. Note this flag is a sufficient reason for those nulls, never a necessary one: `hasPriceGap` and the market figures also null on a market too thin or too mixed to compare, with this flag absent. Absent on healthy runs (the normal path).","allOf":[{"$ref":"#/components/schemas/PricingUnavailableResponse"}]}},"required":["domain","hasFreePlan","hasFreeTrial","hasEnterprisePricing","tierCount"]},"PricingSummaryPriceCompetitorResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Competitor domain","example":"rival.com"},"price":{"type":"number","description":"Price (numeric)","example":99},"planName":{"type":"string","description":"Plan name","example":"Enterprise"}},"required":["domain","price","planName"]},"PricingDashboardSummaryResponse":{"type":"object","properties":{"competitorsChecked":{"type":"number","description":"Number of competitor pricing pages checked","example":6},"competitorsSuccessful":{"type":"number","description":"Number of competitor pricing pages successfully analyzed","example":5},"customer":{"description":"Your pricing metrics","allOf":[{"$ref":"#/components/schemas/PricingSummaryCustomerResponse"}]},"marketAvgPrice":{"type":"number","description":"Market average price (numeric, in `marketCurrency`, per `marketPricingUnit`). Only comparable competitor prices are averaged: fixed monthly recurring amounts sharing BOTH that currency and that licensed unit — your own price is never included. Usage-based prices (per-GB, per-request, hourly), one-time prices, prices in other currencies, and prices in a different licensed unit are never averaged; no currency conversion and no unit mixing is ever performed, so a per-seat price and a flat monthly price never land in the same average. Null when fewer than 3 comparable competitor prices exist — treat null as 'not enough comparable market data', never as zero. Always quote it with its unit: '$59 per seat per month' and '$59 per month' are different claims. Data recorded before unit grouping existed carries no `marketPricingUnit` and its average may mix units — check for that field's absence before quoting a historical figure.","example":59,"nullable":true},"marketCurrency":{"type":"string","description":"The currency all market figures are computed in — the majority currency among comparable competitor prices (a tie prefers your own). Together with `marketPricingUnit` it sets `marketAvgPrice`, `pricePositionPercent`, `topPriceCompetitor`, `lowestPriceCompetitor`, and which prices count toward `pricingSampleSize`. Prices in other currencies stay visible on their rows but are excluded. Null when no comparable competitor price exists, or on data recorded before currency capture existed.","example":"USD","nullable":true},"marketPricingUnit":{"type":"string","description":"The licensed unit all market figures are computed in — 'flat' for plain monthly amounts, or 'per-seat' / 'per-license' / similar for per-unit monthly licensing. It is the majority unit among the comparable competitor prices already sharing `marketCurrency` (a tie prefers your own), and it is the second half of what makes a market figure comparable: competitors pricing in a different unit stay visible on their rows but are excluded from every market figure, because averaging '$15 per seat per month' with '$475 per month' describes no market that exists. Null when no comparable competitor price exists. ABSENT on data recorded before unit grouping existed — on that data the market figures were averaged across mixed units, so the absence of this field is your signal not to quote them as a like-for-like market price.","example":"flat","nullable":true},"pricePositionPercent":{"type":"number","description":"Your price position vs market average (percent; negative = below market). Null when `marketAvgPrice` is null, and ALSO null whenever your own price isn't comparable to the market — a different currency (no conversion is ever performed), a different licensed unit (compare `popularPlanUnit` with `marketPricingUnit`), or non-monthly pricing. Treat null as 'no comparable position', never as 0%.","example":45,"nullable":true},"topPriceCompetitor":{"description":"Competitor with the highest comparable price (fixed monthly, in `marketCurrency`, per `marketPricingUnit`). Descriptive, not a market verdict: present at any sample size — caption it with `pricingSampleSize`. Null when no comparable competitor price exists. A competitor priced in another currency or another unit may well charge more and still not appear here. Data recorded before this change stored null whenever `pricingIsReliable` was false.","nullable":true,"allOf":[{"$ref":"#/components/schemas/PricingSummaryPriceCompetitorResponse"}]},"lowestPriceCompetitor":{"description":"Competitor with the lowest comparable price (fixed monthly, in `marketCurrency`, per `marketPricingUnit`). Same semantics as `topPriceCompetitor` — descriptive at any sample size, null when no comparable competitor price exists.","nullable":true,"allOf":[{"$ref":"#/components/schemas/PricingSummaryPriceCompetitorResponse"}]},"pricingSampleSize":{"type":"number","description":"Number of competitor prices comparable enough to aggregate: fixed monthly recurring amounts sharing both `marketCurrency` and `marketPricingUnit`, your own domain always excluded. This is the honest denominator behind `marketAvgPrice`. Competitors with usage-based, one-time, other-currency, or other-unit pricing don't count here — their pricing model is still visible on their rows, and a low number here usually means the market prices in several different ways rather than that we failed to read it. On data recorded before this change, this counted any numeric price regardless of unit or currency.","example":4},"pricingIsReliable":{"type":"boolean","description":"True when `pricingSampleSize` >= 3 — enough comparable competitor prices for market-average and position claims. When false, `marketAvgPrice`, `pricePositionPercent` and `hasPriceGap` are all null: there is no market average to sit above, so there is no verdict either. `topPriceCompetitor` / `lowestPriceCompetitor` remain as descriptive data points.","example":true},"competitorsWithFreePlan":{"type":"number","description":"Number of competitors offering a free plan","example":2},"competitorsWithFreeTrial":{"type":"number","description":"Number of competitors offering a free trial","example":4},"competitorsWithEnterprise":{"type":"number","description":"Number of competitors with enterprise pricing","example":3},"activePromotionCount":{"type":"number","description":"Number of competitors running active promotions","example":1},"hasPriceGap":{"type":"boolean","description":"Whether you sit 20%+ above the market average. `false` means measured, with no gap. Null in three cases, all of them 'we couldn't make the comparison': your own pricing wasn't measured this run; `pricingIsReliable` is false (too few comparable competitor prices to establish a market average); or `pricePositionPercent` is null because your own price isn't comparable to the market — a different currency, a different licensed unit, or usage-based rather than a fixed monthly amount. That is a WIDER set of null conditions than the other two gap flags carry, so check this field's own list rather than assuming all three null together. Never read a null as 'no gap'.","example":true,"nullable":true},"hasFreeTierGap":{"type":"boolean","description":"Whether at least half of your tracked competitors offer a free tier and you don't. `false` means measured, with no gap. Null when either side of the comparison is missing — those are its only two null conditions: your own pricing wasn't measured this run, or no competitor was measured at all (with no market there is no majority to be behind). It does NOT null on a thin comparable-price sample, because free-tier availability doesn't depend on prices being comparable: a `false` here alongside a null `hasPriceGap` is consistent, not contradictory. Read `competitorsWithFreePlan` alongside it.","example":false,"nullable":true},"hasEnterpriseGap":{"type":"boolean","description":"Whether competitors offer enterprise pricing and you don't. Null when either side of the comparison is missing — your own pricing unmeasured, or no competitor measured. Those are its only two null conditions; like `hasFreeTierGap`, and unlike `hasPriceGap`, it does not null on a thin comparable-price sample. `false` means measured, with no gap.","example":false,"nullable":true},"hasPromotionAlert":{"type":"boolean","description":"Whether competitors are running promotions you should know about","example":true}},"required":["competitorsChecked","competitorsSuccessful","customer","pricingSampleSize","pricingIsReliable","competitorsWithFreePlan","competitorsWithFreeTrial","competitorsWithEnterprise","activePromotionCount","hasPriceGap","hasFreeTierGap","hasEnterpriseGap","hasPromotionAlert"]},"PricingPlanResponse":{"type":"object","properties":{"name":{"type":"string","description":"Plan name","example":"Professional"},"price":{"type":"string","description":"Plan price","example":"$49/month"},"summary":{"type":"string","description":"Brief summary of the plan","example":"Best for growing teams with advanced features"}},"required":["name","price","summary"]},"PricingCompetitorContentResponse":{"type":"object","properties":{"pageTitle":{"type":"string","description":"Page title","example":"Acme Pricing - Plans for Every Team"},"pageDescription":{"type":"string","description":"Page meta description","example":"Choose from flexible pricing plans starting at $9/month."},"mainHeadline":{"type":"string","description":"Main pricing page headline","example":"Simple, Transparent Pricing"},"pricingModel":{"type":"string","description":"Pricing model type","example":"per-seat"},"plans":{"description":"Structured pricing plans extracted from the page","type":"array","items":{"$ref":"#/components/schemas/PricingPlanResponse"}},"popularPlanName":{"type":"string","description":"Name of the recommended/popular plan","example":"Professional"},"hasFreePlan":{"type":"boolean","description":"Whether a free plan is offered","example":true},"freePlanName":{"type":"string","description":"Name of the free plan if offered","example":"Starter"},"hasFreeTrial":{"type":"boolean","description":"Whether a free trial is offered","example":true},"freeTrialDuration":{"type":"string","description":"Free trial duration if offered","example":"14 days"},"billingCycleOffered":{"type":"string","description":"Billing cycles offered","example":"monthly, annual"},"annualDiscountAmount":{"type":"string","description":"Annual discount amount if offered","example":"20%"},"moneyBackGuarantee":{"type":"string","description":"Money-back guarantee details","example":"30-day money-back guarantee"},"hasEnterpriseCustomPricing":{"type":"boolean","description":"Whether enterprise/custom pricing is available","example":true},"enterprisePlanCTA":{"type":"string","description":"Enterprise plan CTA text","example":"Contact Sales"},"pricingPageFeatures":{"type":"string","description":"Key features listed on pricing page (comma-separated)","example":"Unlimited projects, Priority support, SSO"},"hasActivePromotion":{"type":"boolean","description":"Whether an active promotion is running","example":false},"promotionDetails":{"type":"string","description":"Promotion details if active","example":""}},"required":["pageTitle","pageDescription","mainHeadline","pricingModel","plans","popularPlanName","hasFreePlan","freePlanName","hasFreeTrial","freeTrialDuration","billingCycleOffered","annualDiscountAmount","moneyBackGuarantee","hasEnterpriseCustomPricing","enterprisePlanCTA","pricingPageFeatures","hasActivePromotion","promotionDetails"]},"PricingCompetitorUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false — present only when this row carries no pricing data","example":false},"reason":{"type":"string","description":"Why this row has no pricing data. 'no_page_found' = we didn't find a pricing page for this competitor — they may simply not publish one; if you know the URL, adding it in competitor settings enables this check. 'fetch_failed_their_side' = their pricing page didn't respond to this check (blocked, errored, or timed out) — nothing was measured. 'fetch_failed_our_side' = a temporary problem on our side stopped this check — nothing was measured, and it says nothing about the competitor. Never turn any of these reasons into a pricing claim: a row carrying one has no price, plan, or tier facts to quote.","example":"no_page_found","enum":["no_page_found","fetch_failed_their_side","fetch_failed_our_side"]}},"required":["available","reason"]},"PricingCompetitorResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Competitor domain","example":"competitor.com"},"isOwn":{"type":"boolean","description":"Whether this is the project's own domain","example":false},"pricingUrl":{"type":"string","description":"Pricing page URL that was analyzed, or null exactly when `pricingDataAvailable` is present (no pricing data on this row).","example":"https://competitor.com/pricing","nullable":true},"pricingLocationType":{"type":"string","description":"How the pricing page was located, or null exactly when `pricingDataAvailable` is present.","example":"crawled","nullable":true},"content":{"description":"Extracted pricing page content, or null exactly when `pricingDataAvailable` is present. A null here means this competitor's pricing is unmeasured — never 'they offer no pricing'.","nullable":true,"allOf":[{"$ref":"#/components/schemas/PricingCompetitorContentResponse"}]},"pricingDataAvailable":{"description":"Present ONLY when this row has no pricing data — `reason` says why. When present, `pricingUrl`, `pricingLocationType`, and `content` are null: branch on this field before quoting any price, plan, or pricing fact from the row. Absent on measured rows, including every response from before this field existed. On the dashboard, competitor rows keep their most recent successful read, so this marker appears only for a competitor never read successfully — its `reason` reflects the latest completed check. Your own row is different: it always reflects the latest completed check, success or failure. In run-detail responses the marker is scoped to that run.","allOf":[{"$ref":"#/components/schemas/PricingCompetitorUnavailableResponse"}]}},"required":["domain","isOwn","pricingUrl","pricingLocationType","content"]},"PricingDashboardResponse":{"type":"object","properties":{"lastUpdatedAt":{"type":"string","description":"When this data was last updated (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary with pricing gap analysis","allOf":[{"$ref":"#/components/schemas/PricingDashboardSummaryResponse"}]},"competitors":{"description":"Per-competitor pricing data. Every competitor tracked by the project appears, your own domain included — rows without pricing data carry `pricingDataAvailable` saying why instead of being dropped. Competitor rows show the most recent successful read (which may predate the latest run); your own row reflects the latest completed check.","type":"array","items":{"$ref":"#/components/schemas/PricingCompetitorResponse"}}},"required":["lastUpdatedAt","summary","competitors"]},"PricingHistoryItemResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Run ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this run completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary statistics","allOf":[{"$ref":"#/components/schemas/PricingDashboardSummaryResponse"}]}},"required":["runId","completedAt","summary"]},"PricingRunDetailResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Run ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this run completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Run summary statistics","allOf":[{"$ref":"#/components/schemas/PricingDashboardSummaryResponse"}]},"competitors":{"description":"Per-competitor pricing data for this run. Every competitor this run attempted appears — rows the run could not measure carry `pricingDataAvailable` saying why instead of being dropped.","type":"array","items":{"$ref":"#/components/schemas/PricingCompetitorResponse"}}},"required":["runId","completedAt","summary","competitors"]},"AiVisibilityProviderMetricResponse":{"type":"object","properties":{"mentioned":{"type":"boolean","description":"Whether this model's counted answers named the customer. `false` means the customer was not named in the answers this check counted for this model — it is not proof that the model was asked and stayed silent. Under the full-coverage gate a check publishes only if every query it asked was read — it returned a usable answer, or the model was read and had no answer to show (`noAnswerShown`) — and there `false` is a MEASURED absence. Checks published before that gate stay published and can have counted fewer answers than they asked queries, so an answer that never arrived could have carried a mention. This response does not expose the queries-sent figure, so a caller cannot tell which kind of check this is. Report `false` as 'not mentioned in the answers we have from this model'; never as 'this AI does not mention you'.","example":true},"mentionCount":{"type":"number","description":"How many of this model's counted answers named the customer. Its divisor is `answersCounted` on this same record — state it as a count of that model's answers, and do not divide it by the check-wide `totalQueries`, which counts every model's answers.","example":3},"answersCounted":{"type":"number","description":"This model's answers the check counted. 0 beside mentionCount 0 means the model was read and had no answer to show on every prompt — never that it answered and named nobody.","example":3}},"required":["mentioned","mentionCount","answersCounted"]},"AiVisibilityPerProviderResponse":{"type":"object","properties":{"openai":{"description":"ChatGPT metrics","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderMetricResponse"}]},"claude":{"description":"Claude metrics","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderMetricResponse"}]},"gemini":{"description":"Gemini metrics","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderMetricResponse"}]},"perplexity":{"description":"Perplexity metrics","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderMetricResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews metrics","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderMetricResponse"}]}}},"AiVisibilityPerPromptResponse":{"type":"object","properties":{"promptIndex":{"type":"number","description":"Zero-based index of this prompt, matching `promptIndex` on `answers[]` and the `promptIndex` filter.","example":0},"promptLabel":{"type":"string","description":"Short label for the prompt, truncated to 80 characters, falling back to a ONE-BASED `Prompt N` placeholder when no text was captured — so `promptIndex: 0` labels as 'Prompt 1'. Quote the label to a human and the index to this API; do not read a number out of the label and pass it as `promptIndex`. A display label, not the prompt — the full text is on `answers[].promptText`, and the project's current prompts are on the project endpoint.","example":"best sales forecasting platforms for mid-market teams"},"mentionedBy":{"type":"array","description":"Models that named the customer for this prompt. A model is listed only when it genuinely ranked them, so this is safe to read positively. Reading it negatively needs the usual care: a model missing from the list did not name the customer in the answers this check counted — under the full-coverage gate that is a measured absence where the model answered, but a model with `answersCounted: 0` under `perProvider` was read and had no answer to show on this check, and on a check published before that gate it could be an answer that never arrived.","example":["openai","claude"],"items":{"type":"string","enum":["openai","claude","gemini","perplexity","google_ai_overviews"]}},"score":{"type":"number","description":"0-100 position score for this prompt, averaged over the models whose answers this check counted — never over a fixed model count. Higher is better. The score counts only the top 5 positions in an answer, evenly spaced — first place the most, the last scoring position the least — and nothing below them. It is a reading of WHERE a brand lands when it is named, never of who is ahead: a standing claim — \"you lead\", \"you trail\", \"the leader is X\" — rests on how often each brand is named (presence on the market map, or mentionRate within one check) and never on this score, which can favour a brand named half as often. So it is not a rank and not a percentage of anything. A score of 0 for a brand the answers did name means it sat only in the tail of AI recommendations — below the top 5, or too seldom inside it for the average to register. `mentionedBy` separates the two: non-empty means the models named them here. If `mentionedBy` is empty and you need certainty, fetch `includeAnswers=true&promptIndex=<n>` — a prompt that was answered and did not rank the customer has entries in `answers`, while one that was never measured is in `unansweredQueries`.","example":61}},"required":["promptIndex","promptLabel","mentionedBy","score"]},"AiVisibilityCustomerMetricsResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Customer domain","example":"mycompany.com"},"mentionRate":{"type":"number","description":"Mention rate as a percentage: mentions / `totalQueries` * 100. The divisor is this check's own ANSWER count — the queries that came back with a usable answer — never a fixed number. Under the full-coverage gate (a check publishes only once every query it asked was read) that is the ask — every AI model the check asked × its recorded prompt count — MINUS the queries a model was read for and had no answer to show (`noAnswerShown`); checks published before that gate stay published and can have counted fewer answers than they asked queries, and the queries-sent figure is not on this response. So this is a share of the answers counted, never a share of the queries asked — do not report it as 'mentioned in X% of AI queries'. A `0` is a real rate: none of the answers this check counted named this brand.","example":66.7},"mentionCount":{"type":"number","description":"Number of this check's counted ANSWERS that mentioned the customer, out of `totalQueries`. Not out of the queries sent — that figure is not on this response.","example":6},"aiScore":{"type":"number","description":"AI Visibility Score, 0-100. The score counts only the top 5 positions in an answer, evenly spaced — first place the most, the last scoring position the least — and nothing below them. It is a reading of WHERE a brand lands when it is named, never of who is ahead: a standing claim — \"you lead\", \"you trail\", \"the leader is X\" — rests on how often each brand is named (presence on the market map, or mentionRate within one check) and never on this score, which can favour a brand named half as often. Averaged over the answers this check counted and smoothed over up to the last 5 published checks. Present on every published check, and a `0` is a measured score — never 'unscored'. A score of 0 for a brand the answers did name means it sat only in the tail of AI recommendations — below the top 5, or too seldom inside it for the average to register. Read `mentionRate` beside it to tell that case from a brand no counted answer named.","example":73},"perProvider":{"description":"Per-model breakdown, one entry per AI model this check asked. A model is ABSENT when the check did not ask it — checks keep the model set they were run with, so a check from before a model was added carries no entry for it. Absent means not measured; never read it as 'not mentioned'. A model that is PRESENT with `mentioned: false` is read together with its `answersCounted`: beside a non-zero count it answered and did not name the customer, beside `answersCounted: 0` it was read and had no answer to show on every prompt, which is not a measurement of the customer at all.","allOf":[{"$ref":"#/components/schemas/AiVisibilityPerProviderResponse"}]},"perPrompt":{"description":"How the customer did on each of the project's prompts individually, across the models that answered. The cheap way to answer 'which of my questions am I losing on' — a few small rows, no need to request the raw answers block. One row per prompt the check asked, so the array length is that check's own prompt count.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityPerPromptResponse"}}},"required":["domain","mentionRate","mentionCount","aiScore","perProvider","perPrompt"]},"AiVisibilityTopCompetitorResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Top competitor domain","example":"competitor.com"},"name":{"type":"string","description":"Top competitor brand name","example":"Competitor Inc"},"mentionRate":{"type":"number","description":"Top competitor mention rate percentage. Same divisor as `customer.mentionRate` — the answers this check counted, not the queries sent.","example":88.9}},"required":["domain","name","mentionRate"]},"AiVisibilityCompetitorRankingResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Competitor domain","example":"competitor.com"},"name":{"type":"string","description":"Competitor brand name","example":"Competitor Inc"},"isOwn":{"type":"boolean","description":"Whether this is the customer own domain","example":false},"mentionCount":{"type":"number","description":"Total mentions across the answers this check counted.","example":7},"mentionRate":{"type":"number","description":"Mention rate as percentage. Same divisor as `customer.mentionRate` — the answers this check counted, not the queries sent. This row names a THIRD PARTY: a `0` here is a measured rate — none of the answers this check counted named them — and must be reported as exactly that.","example":77.8},"aiScore":{"type":"number","description":"AI Visibility Score (0-100), on the same rule as `customer.aiScore`. The score counts only the top 5 positions in an answer, evenly spaced — first place the most, the last scoring position the least — and nothing below them. It is a reading of WHERE a brand lands when it is named, never of who is ahead: a standing claim — \"you lead\", \"you trail\", \"the leader is X\" — rests on how often each brand is named (presence on the market map, or mentionRate within one check) and never on this score, which can favour a brand named half as often. This row names a THIRD PARTY, so the reading matters more here than anywhere else on the response. A score of 0 for a brand the answers did name means it sat only in the tail of AI recommendations — below the top 5, or too seldom inside it for the average to register. Reporting a `0` as 'never named' is a false claim about another company published under our name. Read `mentionRate` beside a `0` score: a non-zero rate means the brand was named, and a `0` rate means no counted answer named it.","example":68},"isTracked":{"type":"boolean","description":"Whether this domain is one the project currently monitors, as opposed to one an AI named on its own. Resolved against the project's CURRENT competitor list, so on a historical check it describes today's roster and not the roster at the time of that check — it is not a fact about the check. The customer's own row is always `true`; see `isOwn` to identify it.","example":false}},"required":["domain","name","isOwn","mentionCount","mentionRate","aiScore","isTracked"]},"PromptMarketState":{"type":"string","enum":["rivals_named_in_most_answers","rival_match_too_close_to_call","answers_and_rival_list_disagree"],"description":"Which of the three readings this evidence supports. `rivals_named_in_most_answers` — most of the answers named at least one company the account tracks, so the numbers beside this are measuring the intended competition. `rival_match_too_close_to_call` — some answers named a tracked competitor and some named none, in a proportion this much evidence cannot separate; an undecided reading, not a milder version of the next one. `answers_and_rival_list_disagree` — almost none of the answers named a tracked competitor. That last one does NOT assert that the prompts are wrong. It compares two things the customer supplied, the prompts and the tracked competitor list, so a disagreement means one of them does not match what the models said and this comparison cannot tell you which. A narrow market that genuinely does not come up in AI answers yet produces the same reading, and that is a real finding rather than a defect. Report the disjunction, and point at the stored answers (`includeAnswers=true`) — they are the evidence that settles it."},"AiVisibilityPromptMarketExplanationResponse":{"type":"object","properties":{"code":{"type":"string","description":"Stable identifier for this sentence. Branch on it if you need to; never substitute your own wording for it. The project-level reading and a per-prompt reading carry DIFFERENT codes even where their `state` matches, so the two are not interchangeable and de-duplicating a list by code will not collapse them.","example":"rivals_found_across_answers"},"text":{"type":"string","description":"The sentence to report, in the words every CompetLab surface uses. Render it VERBATIM — do not paraphrase it, shorten it, or compose your own sentence from `state`. The wording is chosen to say only what this comparison can support, and a rewrite reliably says more than that. It carries no fraction and no percentage on purpose: this dimension's headline metric is itself a count of answers, so a number inside this sentence gets read as a visibility rate. The counts are the sibling fields — put them beside the sentence, never inside it.","example":"Your prompts are describing your market well. The AI answers we collected mention companies you already track, so the numbers here are measuring the right competition."}},"required":["code","text"]},"AiVisibilityPromptMarketPromptResponse":{"type":"object","properties":{"promptId":{"type":"string","description":"Stable identifier for the prompt, minted once and kept for the life of that prompt — including when its wording is rewritten. It is the durable way to follow one prompt across checks. Note that the project endpoint returns prompt TEXTS and no ids, so lining a row up with a project's current prompts means matching on `promptTextInProject` below.","example":"7f3c1a2e-9d44-4c8b-9f0e-2b6a5c9d7e10"},"promptTextInProject":{"type":"string","description":"The prompt's wording as the project holds it now — which is also the wording every answer behind this reading was produced by, because answers produced by wording that has since been rewritten are excluded from it. Quote it so a reader can find the prompt in the project's settings, and match on it to line this row up with the project endpoint's prompt list. It will always match a current prompt; `promptId` is the durable handle if you need to follow one across edits.","example":"best sales forecasting platforms for mid-market teams"},"state":{"description":"This prompt's own reading, on the same three-token scale as the project-level `state` and read in exactly the same way. Computed over the answers to THIS prompt alone, so it can differ from the project-level reading — which is the whole reason to look here.","example":"answers_and_rival_list_disagree","allOf":[{"$ref":"#/components/schemas/PromptMarketState"}]},"explanation":{"description":"The sentence to report for this prompt, rendered verbatim like the project-level one. It is deliberately IDENTICAL for every prompt in the same state and never names the prompt — `promptId` and `promptTextInProject` do that. Say it once over a list of prompts rather than repeating a near-identical paragraph per row.","allOf":[{"$ref":"#/components/schemas/AiVisibilityPromptMarketExplanationResponse"}]},"answersNamingAnyRival":{"type":"number","description":"Answers to THIS prompt that named at least one company the account tracks. Membership, not volume: an answer that named four tracked competitors counts once, exactly like an answer that named one.","example":1},"answersMatchingCurrentPromptText":{"type":"number","description":"Divide `answersNamingAnyRival` by THIS figure and nothing else. It is the universe that numerator is drawn from: answers to this prompt as its text stands TODAY. Two exclusions. A query that produced no usable answer is out entirely and is never counted as an answer that named nobody. An answer produced before the customer last edited this prompt's text is also out, because it answers a question nobody is asking any more. Do not compare it to a figure on the market map: this row covers ONE prompt while the map pools every prompt, so it is a fraction of the map's total by construction and that gap is not evidence of anything.","example":20}},"required":["promptId","promptTextInProject","state","explanation","answersNamingAnyRival","answersMatchingCurrentPromptText"]},"AiVisibilityPromptMarketResponse":{"type":"object","properties":{"state":{"description":"Which of the three readings this evidence supports. `rivals_named_in_most_answers` — most of the answers named at least one company the account tracks, so the numbers beside this are measuring the intended competition. `rival_match_too_close_to_call` — some answers named a tracked competitor and some named none, in a proportion this much evidence cannot separate; an undecided reading, not a milder version of the next one. `answers_and_rival_list_disagree` — almost none of the answers named a tracked competitor. That last one does NOT assert that the prompts are wrong. It compares two things the customer supplied, the prompts and the tracked competitor list, so a disagreement means one of them does not match what the models said and this comparison cannot tell you which. A narrow market that genuinely does not come up in AI answers yet produces the same reading, and that is a real finding rather than a defect. Report the disjunction, and point at the stored answers (`includeAnswers=true`) — they are the evidence that settles it.","example":"rivals_named_in_most_answers","allOf":[{"$ref":"#/components/schemas/PromptMarketState"}]},"explanation":{"description":"The sentence to report for this reading, with a stable code beside it. This is payload rather than documentation: it is the only place the reading is expressed in words, and the app, this API and the MCP tools all say these same words. Render `text` verbatim.","allOf":[{"$ref":"#/components/schemas/AiVisibilityPromptMarketExplanationResponse"}]},"answersNamingAnyRival":{"type":"number","description":"How many of the answers behind this reading named at least one company the account tracks. Membership, not volume: an answer that named four tracked competitors counts once, exactly like an answer that named one. It says NOTHING about the customer's own visibility — a window in which every answer named a rival and none named the customer reads healthy here and 0% under `customer.mentionRate`. Never present it as a mention rate, and never pair it with `totalQueries`, which counts a different set over a different window.","example":34},"answersMatchingCurrentPromptText":{"type":"number","description":"Divide `answersNamingAnyRival` by THIS figure and nothing else. It is the universe that numerator is drawn from: answers across this reading's window of checks that were produced by the account's prompts as their text stands TODAY. Two exclusions. A query that produced no usable answer is out entirely and is never counted as an answer that named nobody. An answer produced before the customer last edited a prompt is also out, because it answers a question nobody is asking any more. THIS RESPONSE CARRIES A SECOND ANSWER COUNT drawn from the same window — `marketMap.answersReceived`, which pools every usable answer whatever wording produced it, and is the divisor for brand presence. This figure is never larger than that one and is EQUAL to it whenever no prompt has been reworded inside the window, which is the steady state; seeing the two agree does not mean they are the same field. A difference means a prompt's text changed inside the window, and the size of the difference is how many answers that edit orphaned. Also unrelated to `totalQueries` above, which counts ONE check's answers while this counts a window of them.","example":60},"checksAnalysed":{"type":"number","description":"How many published checks this reading was drawn from. Checks, never days — checks are not daily, and the interval is a schedule setting. Quote it whenever you quote the reading: a low number is the usual reason a reading comes back `rival_match_too_close_to_call`, and that state describes thin evidence rather than weak prompts. Counts the checks that contributed an answer on today's prompt text, so it can sit BELOW `marketMap.checksAnalysed`, which counts every check that contributed any usable answer. The two are drawn from one window and agree unless a prompt was reworded inside it.","example":5},"perPrompt":{"description":"One reading per prompt, each with its own `state` — every prompt this reading could attribute answers to appears here, whether it is reaching the tracked competitor list, failing to, or not yet separable. Read each entry's `state` and report its `explanation.text` verbatim: do NOT treat membership of this list as a fault, because the healthy and the undecided readings are in it too. A prompt is MISSING for exactly one reason — no answer in the window was produced by its current wording, which is what happens for a while after the customer edits it — so absence means 'no evidence for this prompt yet' and never a verdict. An empty array means that was true of every prompt; the array being absent means it was true of the check as a whole. An undecided entry still carries its counts, and those counts are evidence of how thin the sample is, never a position: three answers cannot rank one prompt against another, and presenting a partial share as a score is the error this state exists to prevent. Note that entries make a history row several times larger and appear on every project, so a page of history may hit the response size cap sooner — lower `limit` if `truncated` comes back true.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityPromptMarketPromptResponse"}}},"required":["state","explanation","answersNamingAnyRival","answersMatchingCurrentPromptText","checksAnalysed"]},"AiVisibilityMarketEngineCoverageResponse":{"type":"object","properties":{"answersReceived":{"type":"number","description":"Usable answers this model returned in the window — the total its readings of every brand divide by.","example":15}},"required":["answersReceived"]},"AiVisibilityMarketPerEngineCoverageResponse":{"type":"object","properties":{"openai":{"description":"ChatGPT coverage.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketEngineCoverageResponse"}]},"claude":{"description":"Claude coverage.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketEngineCoverageResponse"}]},"gemini":{"description":"Gemini coverage.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketEngineCoverageResponse"}]},"perplexity":{"description":"Perplexity coverage.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketEngineCoverageResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews coverage.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketEngineCoverageResponse"}]}}},"AiMarketZone":{"type":"string","enum":["named_in_a_quarter_or_more_of_answers","named_in_under_a_tenth_of_answers","share_not_yet_separable"],"description":"Where this share sits once the sample's uncertainty is taken into account. `named_in_a_quarter_or_more_of_answers`: the LOWER bound of the range is above 25% — these companies are the market as the AI models draw it. `named_in_under_a_tenth_of_answers`: the UPPER bound is below 10% — reachable only while `tailIsProvable` is true. `share_not_yet_separable`: the range straddles a line — a real company, sometimes named, that the evidence cannot yet place; this is the group that can still be moved. The token names a measured condition about a third party, never a verdict: say 'named in under a tenth of answers', never 'irrelevant' or 'tail'. Thresholds are the same for every project."},"AiVisibilityMarketPresenceResponse":{"type":"object","properties":{"answersNaming":{"type":"number","description":"Answers in this view — the pooled map, or one AI model's reading — that named the brand. Deduplicated within an answer — a brand named twice in one answer counts once — and matched on the domain, never on the brand's wording.","example":31},"answersReceived":{"type":"number","description":"The universe `answersNaming` is drawn from: usable answers in this view. Queries that produced no usable answer are out of it entirely. Quote it whenever you quote the share.","example":60},"presence":{"type":"number","description":"`answersNaming / answersReceived × 100`, whole percent. THE ordering of every brand list here: how often a brand is named, never how high it sat. A share of answers analysed — never of queries sent, and never a probability. Always read with `presenceLow` / `presenceHigh` beside it.","example":52},"presenceLow":{"type":"number","description":"Lower bound of a 95% interval on `presence`, in the same percent scale. Two brands whose ranges overlap are NOT ordered, whatever their rank says — never state that one is ahead of the other.","example":40},"presenceHigh":{"type":"number","description":"Upper bound of the same interval. See `presenceLow`.","example":63},"zone":{"description":"Where this share sits once the sample's uncertainty is taken into account. `named_in_a_quarter_or_more_of_answers`: the LOWER bound of the range is above 25% — these companies are the market as the AI models draw it. `named_in_under_a_tenth_of_answers`: the UPPER bound is below 10% — reachable only while `tailIsProvable` is true. `share_not_yet_separable`: the range straddles a line — a real company, sometimes named, that the evidence cannot yet place; this is the group that can still be moved. The token names a measured condition about a third party, never a verdict: say 'named in under a tenth of answers', never 'irrelevant' or 'tail'. Thresholds are the same for every project.","example":"named_in_a_quarter_or_more_of_answers","allOf":[{"$ref":"#/components/schemas/AiMarketZone"}]}},"required":["answersNaming","answersReceived","presence","presenceLow","presenceHigh","zone"]},"AiVisibilityMarketPerEngineResponse":{"type":"object","properties":{"openai":{"description":"ChatGPT's reading of this brand.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketPresenceResponse"}]},"claude":{"description":"Claude's reading of this brand.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketPresenceResponse"}]},"gemini":{"description":"Gemini's reading of this brand.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketPresenceResponse"}]},"perplexity":{"description":"Perplexity's reading of this brand.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketPresenceResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews' reading of this brand.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketPresenceResponse"}]}}},"AiVisibilityBrandProfileReadingResponse":{"type":"object","properties":{"value":{"type":"number","description":"The reading, a whole number on its own scale — see the field that carries it for the scale. Always read with `answersRead` beside it.","example":64},"answersRead":{"type":"number","description":"Answers in the window that carried this reading for the brand — the universe it was averaged over. Fewer than the answers that named the brand whenever a model that describes nobody (Google AI Overviews) did some of the naming. Under 6 answers the figure is a label, not a measurement: consecutive windows share most of their evidence, so it holds perfectly still until an answer leaves the window and then jumps — report it with its count, never as a position, and never compare two brands on it.","example":9},"low":{"type":"number","description":"Lower bound of a 95% interval on `value`, in the same scale. THE RULE THAT BINDS `presence` BINDS THIS: two brands whose intervals overlap are NOT ordered on this reading, whatever their values say — never state that one is described more warmly, or read as pricier, than the other. `null` on a reading of one answer, which has no spread to measure; never a zero-width interval.","example":51,"nullable":true},"high":{"type":"number","description":"Upper bound of the same interval. See `low`.","example":77,"nullable":true}},"required":["value","answersRead","low","high"]},"AiVisibilityBrandPriceReadingResponse":{"type":"object","properties":{"value":{"type":"number","description":"The reading, a whole number on its own scale — see the field that carries it for the scale. Always read with `answersRead` beside it.","example":64},"answersRead":{"type":"number","description":"Answers in the window that carried this reading for the brand — the universe it was averaged over. Fewer than the answers that named the brand whenever a model that describes nobody (Google AI Overviews) did some of the naming. Under 6 answers the figure is a label, not a measurement: consecutive windows share most of their evidence, so it holds perfectly still until an answer leaves the window and then jumps — report it with its count, never as a position, and never compare two brands on it.","example":9},"low":{"type":"number","description":"Lower bound of a 95% interval on `value`, in the same scale. THE RULE THAT BINDS `presence` BINDS THIS: two brands whose intervals overlap are NOT ordered on this reading, whatever their values say — never state that one is described more warmly, or read as pricier, than the other. `null` on a reading of one answer, which has no spread to measure; never a zero-width interval.","example":51,"nullable":true},"high":{"type":"number","description":"Upper bound of the same interval. See `low`.","example":77,"nullable":true},"tier":{"type":"string","description":"The tier the models stated MOST OFTEN — the word to report. `value` beside it is where the mean of every stated tier sits on the 0–100 line, which can land on a tier nothing stated when the models disagree; say the tier, not the number.","enum":["free","budget","mid_range","premium","enterprise"],"example":"mid_range"},"tierAnswers":{"type":"number","description":"Answers, of `answersRead`, that stated `tier`. Under half means the models do not agree on the tier — say so rather than reporting the mean's position as a price.","example":6}},"required":["value","answersRead","low","high","tier","tierAnswers"]},"AiVisibilityMarketMapBrandResponse":{"type":"object","properties":{"answersNaming":{"type":"number","description":"Answers in this view — the pooled map, or one AI model's reading — that named the brand. Deduplicated within an answer — a brand named twice in one answer counts once — and matched on the domain, never on the brand's wording.","example":31},"answersReceived":{"type":"number","description":"The universe `answersNaming` is drawn from: usable answers in this view. Queries that produced no usable answer are out of it entirely. Quote it whenever you quote the share.","example":60},"presence":{"type":"number","description":"`answersNaming / answersReceived × 100`, whole percent. THE ordering of every brand list here: how often a brand is named, never how high it sat. A share of answers analysed — never of queries sent, and never a probability. Always read with `presenceLow` / `presenceHigh` beside it.","example":52},"presenceLow":{"type":"number","description":"Lower bound of a 95% interval on `presence`, in the same percent scale. Two brands whose ranges overlap are NOT ordered, whatever their rank says — never state that one is ahead of the other.","example":40},"presenceHigh":{"type":"number","description":"Upper bound of the same interval. See `presenceLow`.","example":63},"zone":{"description":"Where this share sits once the sample's uncertainty is taken into account. `named_in_a_quarter_or_more_of_answers`: the LOWER bound of the range is above 25% — these companies are the market as the AI models draw it. `named_in_under_a_tenth_of_answers`: the UPPER bound is below 10% — reachable only while `tailIsProvable` is true. `share_not_yet_separable`: the range straddles a line — a real company, sometimes named, that the evidence cannot yet place; this is the group that can still be moved. The token names a measured condition about a third party, never a verdict: say 'named in under a tenth of answers', never 'irrelevant' or 'tail'. Thresholds are the same for every project.","example":"named_in_a_quarter_or_more_of_answers","allOf":[{"$ref":"#/components/schemas/AiMarketZone"}]},"domain":{"type":"string","description":"Brand domain — the identity every count is matched on.","example":"sienge.com.br"},"name":{"type":"string","description":"Brand name as the models most recently wrote it.","example":"Sienge"},"isOwn":{"type":"boolean","description":"Whether this is the customer's own row. The customer's row is ALWAYS on the map, at zero when no answer named them — that zero is a measured finding.","example":false},"rankByPresence":{"type":"number","description":"Rank by how often the brand is named: one plus the number of brands named more often. Ties SHARE a rank — several brands at the same share read as one rank, never as consecutive ones — so say '7th of 9 by how often it is named', and never break a tie.","example":2},"perEngine":{"description":"This brand on each AI model that answered in the window. The pooled figures above are a vote across models — which AI models back a row — and a brand core to one model and a brand core to all of them look identical on the pooled figure, so read this before saying a brand is 'core to the market'. A model absent here returned no usable answer in the window; a slice at zero is a model that answered and never named the brand.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketPerEngineResponse"}]},"endorsement":{"description":"How warmly the models that describe brands recommend this one, on a scale from 21 to 100: the tone each answer gave it (highly recommended down to merely mentioned) and the role (a direct pick down to a fallback), weighted and averaged over the answers in the window that carried both, pooled the same way as `presence`. THE SCALE HAS A FLOOR, NOT A ZERO — a bare mention offered as a fallback still scores 21 — so read a low figure against 21, never against 0, and NEVER set it against `presence`: the two are different units, and a brand named in under 21% of answers sits on one side of any such comparison by arithmetic alone. There is no gap between them to report. It is the models' own labels on their own answers, not an independent assessment of the brand. `null` means no answer in the window described the brand — named only by a model that describes nobody, or not named at all — never a low reading. Say 'the models describe it as a fallback option' from a figure near the floor, and 'as a leading choice' near 100; the state named on `customerStanding` is the comparison to make for the customer's own row.","nullable":true,"allOf":[{"$ref":"#/components/schemas/AiVisibilityBrandProfileReadingResponse"}]},"pricePerception":{"description":"Where the models that describe brands place this one's price (shown in the app as 'Price read') — the tier they stated most often, with the mean's position on a line from free (0) through budget, mid-range and premium to enterprise (100), pooled the same way as `presence`. What the models THINK it costs, never its price list: the scale has one thin independent witness and is not validated beyond the cheap end. `null` means no answer in the window stated a tier for it, never 'free'. Report the tier word with its share of the answers, and say the models disagree when `tierAnswers` is under half of `answersRead`.","nullable":true,"allOf":[{"$ref":"#/components/schemas/AiVisibilityBrandPriceReadingResponse"}]}},"required":["answersNaming","answersReceived","presence","presenceLow","presenceHigh","zone","domain","name","isOwn","rankByPresence","perEngine","endorsement","pricePerception"]},"AiVisibilityMarketMapResponse":{"type":"object","properties":{"checksAnalysed":{"type":"number","description":"Published checks pooled into this map — this check plus up to the previous 4. Checks, never days: the interval is a schedule setting, and a three-day and a monthly project showing the same `answersReceived` are looking at equally trustworthy maps.","example":5},"answersReceived":{"type":"number","description":"Usable answers pooled across those checks and every AI model — the total every pooled `presence` on `brands` divides by. Quote it whenever you quote a share. This counts EVERY usable answer in the window, whatever prompt wording produced it, which is what a market picture needs: an answer naming a rival is evidence about the market even if the question that drew it has since been reworded. `promptMarket.answersMatchingCurrentPromptText` counts the narrower set produced by today's wording over the same window, so it is never larger than this and is equal whenever no prompt has been reworded — the two agreeing is the steady state, not a sign they are one field. Divide a brand's `answersNaming` by THIS figure and nothing else.","example":60},"tailIsProvable":{"type":"boolean","description":"Whether the sample is large enough for ANY brand to reach `named_in_under_a_tenth_of_answers` — the upper bound of a brand named in exactly one answer falls below 10% — 56 answers with the interval used, on the rounded bounds this response carries. While false no brand is in that zone, and the honest sentence is 'no brand can be ruled out of this market yet' — never 'tail', never 'irrelevant'.","example":true},"coreSize":{"type":"number","description":"Brands in `named_in_a_quarter_or_more_of_answers` — the size of the market as the models draw it. Lead with it: 'Nine companies make up this market as the AI models draw it.'","example":9},"perEngine":{"description":"Usable answers per AI model in the window. A model absent here returned no usable answer in the window.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketPerEngineCoverageResponse"}]},"profileEngines":{"type":"array","description":"The AI models the `endorsement` and `pricePerception` readings on `brands` were read from — those with at least one usable answer in the window that described a company. A model that names companies without describing them (Google AI Overviews) is never here, and a model that returned no usable answer in the window is not here either. Name them whenever you quote a reading. Empty means no answer in the window described anybody, and every reading on `brands` is `null`.","example":["openai","claude","gemini","perplexity"],"items":{"type":"string","enum":["openai","claude","gemini","perplexity","google_ai_overviews"]}},"brands":{"description":"Every brand any answer in the window named, plus the customer's own row, in the ORDER TO RENDER: `presence` desc, then domain. Never re-sort it by anything positional — there is nothing positional to sort by, by design. Read `isOwn` to find the customer; `rankByPresence` says where they sit and shares its value across ties.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityMarketMapBrandResponse"}}},"required":["checksAnalysed","answersReceived","tailIsProvable","coreSize","perEngine","profileEngines","brands"]},"AiVisibilityDashboardSummaryResponse":{"type":"object","properties":{"customer":{"description":"Customer metrics","allOf":[{"$ref":"#/components/schemas/AiVisibilityCustomerMetricsResponse"}]},"topCompetitor":{"description":"Top competitor by mention rate","nullable":true,"allOf":[{"$ref":"#/components/schemas/AiVisibilityTopCompetitorResponse"}]},"mentionRateGap":{"type":"number","description":"Gap between customer and top competitor mention rate (negative means customer is behind), or null when this check found no competitor to compare against — `topCompetitor` is null on the same response. A `0` means genuinely level; a null means there was nobody to be level with.","example":-22.2,"nullable":true},"totalCompetitorsFound":{"type":"number","description":"Total unique competitors found across AI responses","example":8},"totalQueries":{"type":"number","description":"Answers this check counted — the queries that came back with a usable answer. Despite the field name this is NOT the number of queries sent; that figure is not on this response and cannot be derived from it. This is the divisor of every rate and score here. Under the full-coverage gate it is the ask — every AI model the check asked × every prompt it ran — minus the queries a model was read for and had no answer to show (`noAnswerShown`); checks published before that gate stay published and can have counted fewer answers than they asked queries, so this can be lower than what that check sent. Never describe this number as the queries asked. When `latestCheckDataAvailable` is present, this count describes the earlier check the other fields came from.","example":12},"totalEntries":{"type":"number","description":"Total brand entries across this check's counted answers. Also the size preview for `includeAnswers=true`: an entry serializes to roughly 375 tokens, so a 60-entry check runs around 25k tokens unfiltered and a five-engine check nearer 46k, while `brand=` returns about one entry per answer and lands nearer 2k — or roughly 9k when Google AI Overviews is in the ask, whose overview text and cited pages the brand filter keeps, plus the overview text and cited pages on each Google AI Overviews answer, which the brand filter keeps.","example":45},"competitorRankings":{"description":"Every brand this check's counted answers named, plus the customer's own row, in the ORDER TO RENDER: `mentionRate` desc, then `aiScore` desc, then domain. One check's view; the windowed view with its uncertainty is `marketMap`. Nothing here is positional, so never re-sort or compare rows by position.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityCompetitorRankingResponse"}},"promptMarket":{"description":"Whether this project's prompts are reaching the market its tracked competitor list describes — a reading about the QUESTIONS we ask, not about the brand's visibility. Every other number in this summary is arithmetic over those prompts, so this is the field that says whether they are measuring the right market at all. ABSENT means no reading could be produced: the account tracks no competitors to test the prompts against, or no answer came back to test them with. There is deliberately no token for that case, so absence is the only way it is expressed — and absence NEVER means the prompts are fine. Do not report a missing `promptMarket` as a pass, and do not infer one from the numbers beside it. This reading suppresses nothing: everything else in this summary is complete and published whatever it says, because a customer may have chosen unusual prompts deliberately and their data is the only evidence they can judge that on.","allOf":[{"$ref":"#/components/schemas/AiVisibilityPromptMarketResponse"}]},"marketMap":{"description":"THE MARKET MAP — which companies the AI models recommend in this project's category, how often each is named over the last few checks, where the customer sits among them, and which of those companies the project is not tracking. Lead with it: 'Nine companies make up this market as the AI models draw it; the customer is one of them, 7th of 9 by how often it is named.' Every share is a share of answers analysed with its range beside it; two brands whose ranges overlap are not ordered. Read `promptMarket` first: if it is not `rivals_named_in_most_answers`, say the prompts may not describe this project's market and that the map below cannot be trusted — a confident map of the wrong market is the worst output this product can produce. Not returned on history rows; see the dashboard or a check's detail.","allOf":[{"$ref":"#/components/schemas/AiVisibilityMarketMapResponse"}]}},"required":["customer","mentionRateGap","totalCompetitorsFound","totalQueries","totalEntries","competitorRankings","marketMap"]},"AiVisibilityUntrackedCoreBrandResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Brand domain.","example":"sienge.com.br"},"name":{"type":"string","description":"Brand name.","example":"Sienge"},"presence":{"type":"number","description":"Share of answers naming it — see `marketMap.brands[].presence`.","example":52},"presenceLow":{"type":"number","description":"Lower bound of the range.","example":40},"presenceHigh":{"type":"number","description":"Upper bound of the range.","example":63},"answersNaming":{"type":"number","description":"Answers naming it.","example":31},"answersReceived":{"type":"number","description":"Answers analysed.","example":60}},"required":["domain","name","presence","presenceLow","presenceHigh","answersNaming","answersReceived"]},"AiVisibilityCustomerStandingExplanationResponse":{"type":"object","properties":{"code":{"type":"string","description":"Stable code for the sentence — the state token.","example":"described_less_warmly_than_some_core_companies"},"text":{"type":"string","description":"The sentence, rendered verbatim by every surface.","example":"Of the 7 core companies with enough answers to compare, the AI models describe you less warmly than 3; the evidence cannot yet separate you from the other 4."}},"required":["code","text"]},"AiVisibilityCustomerEndorsementStandingResponse":{"type":"object","properties":{"explanation":{"$ref":"#/components/schemas/AiVisibilityCustomerStandingExplanationResponse"},"answersRead":{"type":"number","description":"Answers in the window that carried this reading for the customer.","example":8},"coreCompared":{"type":"number","description":"Core companies with a settled reading and an interval the customer's was set against — at least 3 whenever a comparison was made, and 0 in the too-few state, where nothing was compared. The three counts below sum to it.","example":7},"coreAboveYou":{"type":"number","description":"Core companies whose whole interval sits ABOVE the customer's — the customer is separably below each of them.","example":3},"coreBelowYou":{"type":"number","description":"Core companies whose whole interval sits BELOW the customer's.","example":0},"coreNotSeparable":{"type":"number","description":"Core companies whose interval overlaps the customer's — no order between them and the customer may be stated. Usually most of the core: the customer is usually the least-described brand on their own map and so carries the widest interval.","example":4},"state":{"type":"string","description":"`state` names a measured condition about the customer's OWN row against the companies at the core of this market (`named_in_a_quarter_or_more_of_answers`, never the customer), on this one reading — like against like, never a reading against a share of answers — and it says ONLY what the intervals separate: a core company counts as above the customer only when its whole interval sits above the customer's, below only when wholly below, and the rest are not separable. `explanation.text` is the sentence to report, VERBATIM: never paraphrase it and never build a claim of your own from the token or the counts. `answersRead` is how many answers the customer's reading rests on; `coreCompared` how many core companies carried an interval to compare against, split into `coreAboveYou`, `coreBelowYou` and `coreNotSeparable`; quote them as the sentence does. `too_few_answers_described_you` means fewer than 6 answers carried a reading for the customer, and the figure on their row is a label that will move when an answer leaves the window — not a comparison.","enum":["described_less_warmly_than_every_core_company","described_less_warmly_than_some_core_companies","not_separable_from_the_core","described_more_warmly_than_some_core_companies","described_more_warmly_than_every_core_company","described_between_core_companies","too_few_answers_described_you"],"example":"described_less_warmly_than_some_core_companies"}},"required":["explanation","answersRead","coreCompared","coreAboveYou","coreBelowYou","coreNotSeparable","state"]},"AiVisibilityCustomerPriceStandingResponse":{"type":"object","properties":{"explanation":{"$ref":"#/components/schemas/AiVisibilityCustomerStandingExplanationResponse"},"answersRead":{"type":"number","description":"Answers in the window that carried this reading for the customer.","example":8},"coreCompared":{"type":"number","description":"Core companies with a settled reading and an interval the customer's was set against — at least 3 whenever a comparison was made, and 0 in the too-few state, where nothing was compared. The three counts below sum to it.","example":7},"coreAboveYou":{"type":"number","description":"Core companies whose whole interval sits ABOVE the customer's — the customer is separably below each of them.","example":3},"coreBelowYou":{"type":"number","description":"Core companies whose whole interval sits BELOW the customer's.","example":0},"coreNotSeparable":{"type":"number","description":"Core companies whose interval overlaps the customer's — no order between them and the customer may be stated. Usually most of the core: the customer is usually the least-described brand on their own map and so carries the widest interval.","example":4},"state":{"type":"string","description":"`state` names a measured condition about the customer's OWN row against the companies at the core of this market (`named_in_a_quarter_or_more_of_answers`, never the customer), on this one reading — like against like, never a reading against a share of answers — and it says ONLY what the intervals separate: a core company counts as above the customer only when its whole interval sits above the customer's, below only when wholly below, and the rest are not separable. `explanation.text` is the sentence to report, VERBATIM: never paraphrase it and never build a claim of your own from the token or the counts. `answersRead` is how many answers the customer's reading rests on; `coreCompared` how many core companies carried an interval to compare against, split into `coreAboveYou`, `coreBelowYou` and `coreNotSeparable`; quote them as the sentence does. 'Read' is deliberate throughout: this is the tier the models think the customer charges, never what the customer charges. `too_few_answers_stated_your_price` means fewer than 6 answers stated a tier for the customer.","enum":["read_as_cheaper_than_every_core_company","read_as_cheaper_than_some_core_companies","price_not_separable_from_the_core","read_as_pricier_than_some_core_companies","read_as_pricier_than_every_core_company","read_as_priced_between_core_companies","too_few_answers_stated_your_price"],"example":"read_as_cheaper_than_every_core_company"}},"required":["explanation","answersRead","coreCompared","coreAboveYou","coreBelowYou","coreNotSeparable","state"]},"AiVisibilityCustomerStandingResponse":{"type":"object","properties":{"endorsement":{"description":"How warmly the models describe the customer against the core of this market. ABSENT when fewer than 3 core companies carry a settled endorsement reading, or the customer's row carries none — a comparison needs both sides, and absence never means 'level'. The sentence counts the core companies it could compare against, which can be fewer than marketMap.coreSize: a core company described in too few answers is left out, and the customer is never counted among them.","allOf":[{"$ref":"#/components/schemas/AiVisibilityCustomerEndorsementStandingResponse"}]},"price":{"description":"Where the models place the customer's price against the core of this market. ABSENT when fewer than 3 core companies carry a settled price reading, or the customer's row carries none. Its count of core companies follows the same rule as the endorsement standing's and can differ from marketMap.coreSize.","allOf":[{"$ref":"#/components/schemas/AiVisibilityCustomerPriceStandingResponse"}]}}},"AiVisibilityLatestCheckUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false. This object exists only to say the most recent check produced nothing publishable.","example":false},"reason":{"type":"string","description":"Why the most recent check produced nothing. `incomplete_coverage` — at least one query it asked could not be read — no usable answer came back from it. A query the model was read for and had no answer to show does not count against coverage.","enum":["incomplete_coverage"],"example":"incomplete_coverage"},"measuredAnswers":{"type":"number","description":"Answers that did come back usable in that check. NOT a visibility figure — a check can be fully covered and still score zero.","example":8},"expectedAnswers":{"type":"number","description":"Answers that check asked for: every AI model it queried, against every prompt it ran — read the number from this field; do not recompute it from today's model count, which is not necessarily that check's. `expectedAnswers` − `measuredAnswers` is the uncounted total, and it is two facts added together: queries that could not be read, plus queries the model was read for that had no answer to show (`absentAnswers`). Subtract `absentAnswers` from that shortfall to get the number of queries that could not be read; report the two apart, never the total as answers that failed to arrive.","example":12},"absentAnswers":{"type":"number","description":"Queries in that check the model was read for and had no answer to show — today, prompts for which Google's results page carried no AI Overview. They sit inside `expectedAnswers` − `measuredAnswers` without being failures: that shortfall minus this figure is the number of queries that could not be read.","example":1}},"required":["available","reason","measuredAnswers","expectedAnswers","absentAnswers"]},"AiVisibilityAnswerLocaleResponse":{"type":"object","properties":{"locationName":{"type":"string","description":"The country or region the question was asked from, as Google names its markets.","example":"United States"},"languageName":{"type":"string","description":"The language the question was asked in, as Google names it.","example":"English"}},"required":["locationName","languageName"]},"AiVisibilityAnswerSourceResponse":{"type":"object","properties":{"url":{"type":"string","description":"Address of the cited page.","example":"https://www.g2.com/categories/sales-forecasting"},"domain":{"type":"string","description":"Domain of the cited page — the site Google cited.","example":"g2.com"},"title":{"type":"string","description":"Title of the cited page, as Google displayed it.","example":"Best Sales Forecasting Software"}},"required":["url","domain","title"]},"AiVisibilityAnswerDifferentiationResponse":{"type":"object","properties":{"axis":{"type":"string","description":"The axis the model framed this brand as competing on. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","enum":["technology","price","service","speed","scale"],"example":"technology"},"uniqueValue":{"type":"string","description":"What the model said sets this brand apart, in its own words. The model's claim, not a verified fact. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":"Only platform with native two-way CRM sync"}}},"AiVisibilityAnswerMessagingResponse":{"type":"object","properties":{"keywords":{"description":"Keywords the model associated with this brand (up to 5). Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":["automation","mid-market","self-serve"],"type":"array","items":{"type":"string"}},"credibilitySignals":{"description":"Claims the model offered as evidence for this brand — certifications, customer counts, awards. The model asserts these; it does not source them and CompetLab does not verify them. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":["SOC 2 Type II","used by 400+ teams"],"type":"array","items":{"type":"string"}},"differentiationClaims":{"description":"Differentiation claims the model attributed to this brand (up to 3). The model's claims about the brand, not verified facts. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":["fastest setup in category"],"type":"array","items":{"type":"string"}}}},"AiVisibilityAnswerBrandResponse":{"type":"object","properties":{"rank":{"type":"number","description":"Position in this model's answer — 1 means named first. For models that answer with a ranked list, this is the rank they gave. For Google AI Overviews, which answers in prose, it is the ORDER OF FIRST MENTION in `answerText`, computed by CompetLab — Google assigned no position, so never report it as a rank Google gave. This is the rank among the brands recorded for this answer: brands are de-duplicated by domain before storage, so it is the position as recorded rather than necessarily the ordinal the model itself emitted. Stable under filtering — narrowing by `provider`, `brand`, or `promptIndex` never renumbers it.","example":1},"name":{"type":"string","description":"Brand name as the model wrote it.","example":"Competitor Inc"},"domain":{"type":"string","description":"Brand domain, normalized.","example":"competitor.com"},"description":{"type":"string","description":"One-sentence description of the brand, in the model's own words. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":"Cloud platform for pipeline forecasting aimed at mid-market sales teams."},"rankingRationale":{"type":"string","description":"The model's stated reason for placing this brand where it did. The single most useful field here for understanding WHY a ranking looks the way it does — and the model's reasoning, not a verified fact. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":"Listed first because it integrates directly with the major CRMs."},"sentiment":{"type":"string","description":"How warmly the model spoke about this brand in this answer. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","enum":["highly_recommended","recommended","mentioned","alternative"],"example":"recommended"},"mentionContext":{"type":"string","description":"The role this brand played in the answer. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","enum":["direct_recommendation","comparison","alternative","niche_fit"],"example":"direct_recommendation"},"targetAudience":{"type":"string","description":"Who the model said this brand is for. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":"Mid-size B2B sales teams"},"pricingSignal":{"type":"string","description":"The price tier the model placed this brand in. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","enum":["free","budget","mid_range","premium","enterprise","unknown"],"example":"mid_range"},"positionConfidence":{"type":"number","description":"0-1. How confidently this brand's position could be read out of the model's answer. It is not a measure of whether the model's ranking is correct. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":0.9},"features":{"description":"Features the model highlighted for this brand (max 5). Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","example":["pipeline forecasting","CRM sync"],"type":"array","items":{"type":"string"}},"differentiation":{"description":"How the model framed this brand's differentiation. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","allOf":[{"$ref":"#/components/schemas/AiVisibilityAnswerDifferentiationResponse"}]},"messaging":{"description":"The messaging the model associated with this brand. Absent when the model that produced this answer does not describe brands — Google AI Overviews names companies in prose and states nothing else about them.","allOf":[{"$ref":"#/components/schemas/AiVisibilityAnswerMessagingResponse"}]}},"required":["rank","name","domain"]},"AiVisibilityAnswerResponse":{"type":"object","properties":{"provider":{"type":"string","description":"Which AI produced this answer.","enum":["openai","claude","gemini","perplexity","google_ai_overviews"],"example":"openai"},"promptIndex":{"type":"number","description":"Zero-based index of the project prompt this answer belongs to. The same index the summary's per-prompt breakdown uses. Stable under filtering.","example":0},"promptText":{"type":"string","description":"The exact prompt sent to the model — one of the project's own AI prompts.","example":"best sales forecasting platforms for mid-market teams"},"answerText":{"type":"string","description":"The answer as the model wrote it. Present for Google AI Overviews: the overview text, verbatim, from which the brands below were read in order of first mention. Absent for models that answer in a ranked list. Model prose, attributed to the model that wrote it.","example":"For mid-market sales teams, forecasting platforms such as Competitor Inc, Rival Software and Example Analytics combine CRM data with AI-driven projections."},"askedIn":{"description":"The Google market the question was asked in. Google AI Overviews only.","allOf":[{"$ref":"#/components/schemas/AiVisibilityAnswerLocaleResponse"}]},"sources":{"description":"The pages cited beside this answer. Google AI Overviews only — the pages Google cited with the overview, distinct by URL, up to 20. ABSENT means either that Google listed no citations or that every one it listed was a masked redirect we could not resolve to a page — never that Google cited nothing. An EMPTY array is the measured 'it cited nothing'. Absent for every other model.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityAnswerSourceResponse"}},"brands":{"description":"Brands this model named, in the order it ranked them — for Google AI Overviews, in the order of first mention in `answerText`, each carrying only `name` and `domain`. Every answer in this array arrived — a query that produced nothing is not here at all, it is in `unansweredQueries`, and a query the model was read for and had no answer to show is in `noAnswerShown`. So an EMPTY list is a measurement, never missing data: under a `brand` filter it means this model answered this question and did not name that domain, which is a real competitive finding and often the most actionable row in the response. Unfiltered it would mean the model named nobody at all. Report an empty list as 'this model answered and did not name them'; NEVER as 'no data', 'we could not measure', or a query that failed.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityAnswerBrandResponse"}}},"required":["provider","promptIndex","promptText","brands"]},"AiVisibilityUnansweredQueryResponse":{"type":"object","properties":{"provider":{"type":"string","description":"Which AI this query was sent to.","enum":["openai","claude","gemini","perplexity","google_ai_overviews"],"example":"gemini"},"promptIndex":{"type":"number","description":"Zero-based index of the project prompt this query was sent for.","example":2},"promptText":{"type":"string","description":"The exact prompt that was sent.","example":"best sales forecasting platforms for mid-market teams"},"reason":{"type":"string","description":"`no_usable_answer` — we sent this query and did not end up with an answer we could use. It names no actor and makes no claim about the prompt or the model. Report it as 'no usable answer from this model for this prompt — not counted'. NEVER report it as 'not mentioned', '0 mentions' or 'didn't appear', and never attach 'failed' to the prompt, which the customer authored. This query is excluded from every count and rate on this response.","enum":["no_usable_answer"],"example":"no_usable_answer"}},"required":["provider","promptIndex","promptText","reason"]},"AiVisibilityNoAnswerShownResponse":{"type":"object","properties":{"provider":{"type":"string","description":"Which AI this query was sent to — we reached it and it had no answer to show.","enum":["openai","claude","gemini","perplexity","google_ai_overviews"],"example":"google_ai_overviews"},"promptIndex":{"type":"number","description":"Zero-based index of the project prompt this query was sent for.","example":1},"promptText":{"type":"string","description":"The exact prompt that was sent.","example":"best sales forecasting platforms for mid-market teams"},"reason":{"type":"string","description":"`no_ai_overview_shown` — Google's results page for this prompt carried no AI Overview. A measured fact about the prompt on Google that day: not a failure, not an answer, and nothing the customer's prompt did wrong. Report it as 'Google showed no AI Overview for this question — not counted'; NEVER as 'not mentioned', '0 mentions', or a query that failed. This query is excluded from every count and rate on this response.","enum":["no_ai_overview_shown"],"example":"no_ai_overview_shown"}},"required":["provider","promptIndex","promptText","reason"]},"AiVisibilityProviderStatusResponse":{"type":"object","properties":{"reported":{"type":"boolean","description":"Whether this model produced a result record for this check. `false` means nothing came back from it at all. `true` does NOT mean it answered usefully: a model can be asked and have every one of its queries come back unusable, which appears here as `reported: true` with `answersCounted: 0`. Read `answersCounted` and `noAnswerShown` together. `answersCounted: 0` beside `noAnswerShown: 0` means we got no usable answer from this model — NEVER that it answered and named nobody — and those queries are listed in `unansweredQueries`. `answersCounted: 0` beside a non-zero `noAnswerShown` means the model was read and had nothing to show, which is not a failure.","example":true},"completedAt":{"type":"string","description":"When this model finished (ISO-8601). Null when it never reported.","example":"2026-03-15T10:00:00.000Z","nullable":true},"answersCounted":{"type":"number","description":"Queries to this model that came back with an answer we could use. This is the discriminator to read `reported` and `brandsNamed` against. A `0` here does not by itself mean failure: read `noAnswerShown` beside it.","example":3},"noAnswerShown":{"type":"number","description":"Queries to this model that were read and had no answer to show — the entries for this model in `noAnswerShown` on this response. Zero for every model but Google AI Overviews today. On a check published under the full-coverage gate, `answersCounted` plus this figure is the number of queries sent to it; on older checks a model that failed before answering anything recorded no slots at all, so the sum can fall short. None of these counts is ever a fraction of another.","example":0},"brandsNamed":{"type":"number","description":"Brand entries this model named across the answers that came back, counting a brand once per answer it appears in. `0` beside `answersCounted: 0` means this model produced no answer to count — not that it named nobody. Only `0` beside a non-zero `answersCounted` means it answered and named nobody.","example":21}},"required":["reported","answersCounted","noAnswerShown","brandsNamed"]},"AiVisibilityProviderStatusMapResponse":{"type":"object","properties":{"openai":{"description":"ChatGPT","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderStatusResponse"}]},"claude":{"description":"Claude","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderStatusResponse"}]},"gemini":{"description":"Gemini","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderStatusResponse"}]},"perplexity":{"description":"Perplexity","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderStatusResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderStatusResponse"}]}}},"AiVisibilityAnswerCoverageResponse":{"type":"object","properties":{"queriesSent":{"type":"number","description":"Queries this check sent: every AI model it queried, against every prompt it ran — read the number from this field; do not recompute it from today's model count, which is not necessarily that check's. Checks keep the model set and the prompt count they were run with.","example":12},"answersCounted":{"type":"number","description":"Queries that came back with an answer we could use. The shortfall is `queriesSent` − `answersCounted`, and it is two different facts added together: `queriesSent` − `answersCounted` = `unansweredQueries.length` + `noAnswerShown.length` — queries we could not measure, plus queries the model was read for and had no answer to show. On a check published under the full-coverage gate the whole shortfall is in `noAnswerShown`; on older checks it can exceed both arrays together, because a model that failed before answering anything recorded no query slots to list. Trust the shortfall, not the array lengths. NEVER present these numbers as a fraction: this dimension's headline metric is already an n-of-N over the same denominator, so any shortfall written as a fraction gets read as a visibility rate. State them as separate facts that cannot be divided by each other — \"We sent every query we planned; Google showed no AI Overview for one of them, so it is not counted\", or, for a query that could not be read, \"one answer didn't come back.\" Always computed over the whole check, never over a filtered view.","example":8}},"required":["queriesSent","answersCounted"]},"AiVisibilityDashboardResponse":{"type":"object","properties":{"lastUpdatedAt":{"type":"string","description":"When the data below was measured (ISO-8601). This is the completion time of the check the summary comes from, which is NOT necessarily the most recent cycle — see `latestCheckDataAvailable`.","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Summary statistics","allOf":[{"$ref":"#/components/schemas/AiVisibilityDashboardSummaryResponse"}]},"untrackedCoreBrands":{"description":"Companies the AI models place in the core of the customer's market that the project does NOT track — the highest-value line this dimension produces. Every other dimension benchmarks against the competitor list, so a core brand missing from it is a hole in the whole account, found here. Resolved against the project's CURRENT competitor list. ABSENT when `summary.promptMarket` is not `rivals_named_in_most_answers` or could not be produced — a recommendation drawn from a map that may describe the wrong market is withheld, and absence never means 'none'. An EMPTY list means the gate is healthy and every core brand is already tracked, which is a real finding. Report it as a recommendation to add these companies, never as a fact about them.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityUntrackedCoreBrandResponse"}},"customerStanding":{"description":"The customer's own standing against the core of this market on the two profile readings — how warmly the models describe them, and where the models place their price — each as a state token and a sentence to report verbatim. Derived from `summary.marketMap` by one shared rule, so the app and this API cannot say different things. A reading of the map, not a recommendation: present whenever a published map is, and read under `summary.promptMarket` exactly as the map is — unless that state is `rivals_named_in_most_answers`, say the market it compares against may not be the customer's. Each reading inside is absent on its own evidence floor, and absence never means 'level'. Never derive a gap, a distance or a rank from these; never set `endorsement` against `presence`.","allOf":[{"$ref":"#/components/schemas/AiVisibilityCustomerStandingResponse"}]},"latestCheckDataAvailable":{"description":"Present ONLY when the project's most recent check was abandoned as incomplete: at least one query it asked could not be read — no usable answer came back from it — so it was never scored. A query the model was read for and had no answer to show does not count against coverage. When present, every other field in this response comes from an EARLIER check, and `lastUpdatedAt` is older than the most recent check attempted. Absent when the most recent check published normally — and absent, too, when an older check fell short but a later one has since published, because that failure has been superseded. An abandoned check is never retried into a score: a new check runs automatically, and the caller may also trigger one immediately. When describing this state, NEVER phrase it as a fraction — this dimension's own metric is already an n-of-N over the same denominator, so any shortfall written as a fraction gets read as a visibility rate. State the two numbers as separate facts that cannot be divided by each other: \"We sent every query we planned, but one answer didn't come back.\"","allOf":[{"$ref":"#/components/schemas/AiVisibilityLatestCheckUnavailableResponse"}]},"answers":{"description":"The models' answers for this check — one entry per query that came back with an answer we could use. Present ONLY when the request set `includeAnswers=true`; absent otherwise, never an empty array standing in for 'not requested'. Narrowed by `provider` and `promptIndex` when those are set — those also narrow the matching entries of `unansweredQueries` and `noAnswerShown`. `brand` narrows DIFFERENTLY: it reduces the `brands` list inside each answer and never this array, so every answer the check counted is still here and the ones that did not name that domain arrive with an empty `brands`. `brand` narrows `unansweredQueries` and `noAnswerShown` not at all, because a query that produced no answer could have named anyone. No filter changes anything else: every number under `summary` is stored, computed over the whole check, and is never recomputed for a filtered view. Per-brand prose — description, ranking rationale, audience, pricing tier, messaging, differentiation — is present only for models that describe brands; a brand row carrying only `rank`, `name` and `domain` means Google AI Overviews named it in prose, and the prose is on that answer's `answerText`. ATTRIBUTION: every piece of prose in this block — descriptions, ranking rationales, audiences, claims, and the overview text itself — is unverified model output about the brands that model named, including third parties CompetLab does not monitor. It is a record of what the model said, not CompetLab's assessment of those brands. Attribute it to the named `provider`; do not republish it as fact.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityAnswerResponse"}},"unansweredQueries":{"description":"Queries this check sent that produced no usable answer. Present ONLY when `includeAnswers=true`. Carried in their own array rather than mixed into `answers` so that `answers.length` always means answers counted and can never be read as queries sent. An empty array means every query this check sent was read — answered, or listed under `noAnswerShown` — which is the normal case, because a check is only scored when every query it asked was read. It can be non-empty on checks published before that rule existed. These queries are excluded from every count and rate on this response. Never pair this array's length with `summary.totalQueries` as a fraction. `totalQueries` counts answers, this counts queries that produced none, and adding them and `noAnswerShown.length` recovers the queries sent — but a shortfall written as a fraction, beside this dimension's headline metric, gets read as a visibility rate. State them as separate facts that cannot be divided by each other.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityUnansweredQueryResponse"}},"noAnswerShown":{"description":"Queries the model was read for and had no answer to show — today, prompts for which Google's results page carried no AI Overview. Not a failure and not an answer: these slots are excluded from every count on this payload. Separate from `unansweredQueries`, which are queries we could not measure. Present ONLY when `includeAnswers=true`. Narrowed by `provider` and `promptIndex`, never by `brand`. An empty array means every query the check sent was either answered or could not be measured.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityNoAnswerShownResponse"}},"providerStatus":{"description":"Per-model reporting status for this check: which of the models this check asked returned data, when, and how much. One entry per model the check ASKED — a model is absent when the check did not ask it, because checks keep the model set they were run with; absent means not measured, never 'did not report'. Present ONLY when `includeAnswers=true`. It carries no visibility judgement, and it is NOT narrowed by the filters — it always describes every model the check asked across the whole check, so a filtered response still says what the full picture was.","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderStatusMapResponse"}]},"answerCoverage":{"description":"How many queries this check sent, and how many came back — both read from the check's own record of its ask, never inferred from the length of `answers`. Present ONLY when `includeAnswers=true`. Always describes the whole check, never a filtered view. Report the two numbers as separate facts, never as a fraction — see `answersCounted`.","allOf":[{"$ref":"#/components/schemas/AiVisibilityAnswerCoverageResponse"}]},"answersTruncated":{"type":"boolean","description":"True when the answers payload hit the response size cap and answers were dropped from the end of `answers`. The cut lands on a WHOLE PROMPT ROUND, never inside one: `answers` is ordered prompt by prompt across the models, so truncation drops the highest `promptIndex` values and no model is ever removed by truncation from a prompt that is still present — group the array by `promptIndex` and no round is short. A model missing from a prompt that IS present is in `unansweredQueries` or `noAnswerShown`, not cut; neither of those arrays is ever truncated, so a prompt absent from `answers` is not a prompt no model answered. Individual `brands` lists are never partially truncated. `summary`, `providerStatus` and `answerCoverage` are never affected. Narrow the response with `provider` or `promptIndex` to get a complete view — `brand` keeps every answer, and each Google AI Overviews answer's overview text and cited pages with it, so it shrinks the payload far less. Present ONLY when `includeAnswers=true`.","example":false}},"required":["lastUpdatedAt","summary"]},"AiProvider":{"type":"string","enum":["openai","claude","gemini","perplexity","google_ai_overviews"]},"ApiValidationErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable error code for a rejected request payload or query. `invalid_parameters` — a query parameter or body field failed validation. `invalid_run_id` — a path parameter naming a run is not a well-formed identifier. `invalid_check_id` — a path parameter naming an AI Visibility or AI Sources check is not a well-formed identifier.","enum":["invalid_parameters","invalid_run_id","invalid_check_id"],"example":"invalid_parameters"},"message":{"type":"string","description":"Human-readable validation message (joined when multiple fields fail).","example":"Domain is required"},"status":{"type":"number","description":"HTTP status code","example":400}},"required":["code","message","status"]},"ApiValidationErrorEnvelope":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/ApiValidationErrorResponse"}},"required":["error"]},"AiVisibilityHistorySummaryResponse":{"type":"object","properties":{"customer":{"description":"Customer metrics","allOf":[{"$ref":"#/components/schemas/AiVisibilityCustomerMetricsResponse"}]},"topCompetitor":{"description":"Top competitor by mention rate","nullable":true,"allOf":[{"$ref":"#/components/schemas/AiVisibilityTopCompetitorResponse"}]},"mentionRateGap":{"type":"number","description":"Gap between customer and top competitor mention rate (negative means customer is behind), or null when this check found no competitor to compare against — `topCompetitor` is null on the same response. A `0` means genuinely level; a null means there was nobody to be level with.","example":-22.2,"nullable":true},"totalCompetitorsFound":{"type":"number","description":"Total unique competitors found across AI responses","example":8},"totalQueries":{"type":"number","description":"Answers this check counted — the queries that came back with a usable answer. Despite the field name this is NOT the number of queries sent; that figure is not on this response and cannot be derived from it. This is the divisor of every rate and score here. Under the full-coverage gate it is the ask — every AI model the check asked × every prompt it ran — minus the queries a model was read for and had no answer to show (`noAnswerShown`); checks published before that gate stay published and can have counted fewer answers than they asked queries, so this can be lower than what that check sent. Never describe this number as the queries asked. When `latestCheckDataAvailable` is present, this count describes the earlier check the other fields came from.","example":12},"totalEntries":{"type":"number","description":"Total brand entries across this check's counted answers. Also the size preview for `includeAnswers=true`: an entry serializes to roughly 375 tokens, so a 60-entry check runs around 25k tokens unfiltered and a five-engine check nearer 46k, while `brand=` returns about one entry per answer and lands nearer 2k — or roughly 9k when Google AI Overviews is in the ask, whose overview text and cited pages the brand filter keeps, plus the overview text and cited pages on each Google AI Overviews answer, which the brand filter keeps.","example":45},"competitorRankings":{"description":"Every brand this check's counted answers named, plus the customer's own row, in the ORDER TO RENDER: `mentionRate` desc, then `aiScore` desc, then domain. One check's view; the windowed view with its uncertainty is `marketMap`. Nothing here is positional, so never re-sort or compare rows by position.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityCompetitorRankingResponse"}},"promptMarket":{"description":"Whether this project's prompts are reaching the market its tracked competitor list describes — a reading about the QUESTIONS we ask, not about the brand's visibility. Every other number in this summary is arithmetic over those prompts, so this is the field that says whether they are measuring the right market at all. ABSENT means no reading could be produced: the account tracks no competitors to test the prompts against, or no answer came back to test them with. There is deliberately no token for that case, so absence is the only way it is expressed — and absence NEVER means the prompts are fine. Do not report a missing `promptMarket` as a pass, and do not infer one from the numbers beside it. This reading suppresses nothing: everything else in this summary is complete and published whatever it says, because a customer may have chosen unusual prompts deliberately and their data is the only evidence they can judge that on.","allOf":[{"$ref":"#/components/schemas/AiVisibilityPromptMarketResponse"}]}},"required":["customer","mentionRateGap","totalCompetitorsFound","totalQueries","totalEntries","competitorRankings"]},"AiVisibilityHistoryItemResponse":{"type":"object","properties":{"checkId":{"type":"string","description":"Check ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this check completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Check summary statistics — the dashboard summary without `marketMap`, which is on the dashboard and on a check's detail.","allOf":[{"$ref":"#/components/schemas/AiVisibilityHistorySummaryResponse"}]}},"required":["checkId","completedAt","summary"]},"AiVisibilityCheckDetailResponse":{"type":"object","properties":{"checkId":{"type":"string","description":"Check ID","example":"507f1f77bcf86cd799439011"},"completedAt":{"type":"string","description":"When this check completed (ISO-8601)","example":"2026-03-15T10:00:00.000Z"},"summary":{"description":"Check summary statistics, including this check's per-competitor rankings under `summary.competitorRankings`.","allOf":[{"$ref":"#/components/schemas/AiVisibilityDashboardSummaryResponse"}]},"answers":{"description":"The models' answers for this check — one entry per query that came back with an answer we could use. Present ONLY when the request set `includeAnswers=true`; absent otherwise, never an empty array standing in for 'not requested'. Narrowed by `provider` and `promptIndex` when those are set — those also narrow the matching entries of `unansweredQueries` and `noAnswerShown`. `brand` narrows DIFFERENTLY: it reduces the `brands` list inside each answer and never this array, so every answer the check counted is still here and the ones that did not name that domain arrive with an empty `brands`. `brand` narrows `unansweredQueries` and `noAnswerShown` not at all, because a query that produced no answer could have named anyone. No filter changes anything else: every number under `summary` is stored, computed over the whole check, and is never recomputed for a filtered view. Per-brand prose — description, ranking rationale, audience, pricing tier, messaging, differentiation — is present only for models that describe brands; a brand row carrying only `rank`, `name` and `domain` means Google AI Overviews named it in prose, and the prose is on that answer's `answerText`. ATTRIBUTION: every piece of prose in this block — descriptions, ranking rationales, audiences, claims, and the overview text itself — is unverified model output about the brands that model named, including third parties CompetLab does not monitor. It is a record of what the model said, not CompetLab's assessment of those brands. Attribute it to the named `provider`; do not republish it as fact.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityAnswerResponse"}},"unansweredQueries":{"description":"Queries this check sent that produced no usable answer. Present ONLY when `includeAnswers=true`. Carried in their own array rather than mixed into `answers` so that `answers.length` always means answers counted and can never be read as queries sent. An empty array means every query this check sent was read — answered, or listed under `noAnswerShown` — which is the normal case, because a check is only scored when every query it asked was read. It can be non-empty on checks published before that rule existed. These queries are excluded from every count and rate on this response. Never pair this array's length with `summary.totalQueries` as a fraction. `totalQueries` counts answers, this counts queries that produced none, and adding them and `noAnswerShown.length` recovers the queries sent — but a shortfall written as a fraction, beside this dimension's headline metric, gets read as a visibility rate. State them as separate facts that cannot be divided by each other.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityUnansweredQueryResponse"}},"noAnswerShown":{"description":"Queries the model was read for and had no answer to show — today, prompts for which Google's results page carried no AI Overview. Not a failure and not an answer: these slots are excluded from every count on this payload. Separate from `unansweredQueries`, which are queries we could not measure. Present ONLY when `includeAnswers=true`. Narrowed by `provider` and `promptIndex`, never by `brand`. An empty array means every query the check sent was either answered or could not be measured.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityNoAnswerShownResponse"}},"providerStatus":{"description":"Per-model reporting status for this check: which of the models this check asked returned data, when, and how much. One entry per model the check ASKED — a model is absent when the check did not ask it, because checks keep the model set they were run with; absent means not measured, never 'did not report'. Present ONLY when `includeAnswers=true`. It carries no visibility judgement, and it is NOT narrowed by the filters — it always describes every model the check asked across the whole check, so a filtered response still says what the full picture was.","allOf":[{"$ref":"#/components/schemas/AiVisibilityProviderStatusMapResponse"}]},"answerCoverage":{"description":"How many queries this check sent, and how many came back — both read from the check's own record of its ask, never inferred from the length of `answers`. Present ONLY when `includeAnswers=true`. Always describes the whole check, never a filtered view. Report the two numbers as separate facts, never as a fraction — see `answersCounted`.","allOf":[{"$ref":"#/components/schemas/AiVisibilityAnswerCoverageResponse"}]},"answersTruncated":{"type":"boolean","description":"True when the answers payload hit the response size cap and answers were dropped from the end of `answers`. The cut lands on a WHOLE PROMPT ROUND, never inside one: `answers` is ordered prompt by prompt across the models, so truncation drops the highest `promptIndex` values and no model is ever removed by truncation from a prompt that is still present — group the array by `promptIndex` and no round is short. A model missing from a prompt that IS present is in `unansweredQueries` or `noAnswerShown`, not cut; neither of those arrays is ever truncated, so a prompt absent from `answers` is not a prompt no model answered. Individual `brands` lists are never partially truncated. `summary`, `providerStatus` and `answerCoverage` are never affected. Narrow the response with `provider` or `promptIndex` to get a complete view — `brand` keeps every answer, and each Google AI Overviews answer's overview text and cited pages with it, so it shrinks the payload far less. Present ONLY when `includeAnswers=true`.","example":false}},"required":["checkId","completedAt","summary"]},"AiVisibilityTrendWindowResponse":{"type":"object","properties":{"from":{"type":"string","description":"When the first published check in the window completed (ISO-8601); null when the window holds none.","example":"2026-08-12T09:30:00.000Z","nullable":true},"to":{"type":"string","description":"When the last published check in the window completed (ISO-8601); null when the window holds none.","example":"2026-08-30T09:30:00.000Z","nullable":true},"checks":{"type":"number","description":"Published checks in the window — the checks the readings were taken at.","example":16},"answersReceived":{"type":"number","description":"Usable answers pooled on the latest map on this scope — the denominator of every `now` reading. Null under a `provider` filter for a model with no usable answer in the latest window.","example":60,"nullable":true},"checksAnalysed":{"type":"number","description":"Published checks pooled into the latest map — every reading is over a window of up to this many checks, never one check alone.","example":5},"providersAsked":{"type":"array","description":"The models the latest check asked, as recorded on it. A check keeps the model set it ran with.","example":["openai","claude","gemini","perplexity","google_ai_overviews"],"items":{"type":"string","enum":["openai","claude","gemini","perplexity","google_ai_overviews"]}}},"required":["from","to","checks","answersReceived","checksAnalysed","providersAsked"]},"AiVisibilityTrendPresenceResponse":{"type":"object","properties":{"answersNaming":{"type":"number","description":"Answers in the check's window that named the company — the numerator. Every prompt asks for a recommendation, so a company named in an answer is one the model recommended. Deduplicated within an answer, matched on the domain.","example":24},"answersReceived":{"type":"number","description":"Usable answers pooled in that window on this scope — the denominator, and the universe of every figure on this reading. Never the number of queries sent.","example":60},"presence":{"type":"number","description":"`answersNaming` / `answersReceived` × 100, rounded to a whole percent. A share of answers, never a probability.","example":40},"presenceLow":{"type":"number","description":"The lower bound of a 95% interval on that share, in whole percent. Two companies whose intervals overlap are NOT in a settled order, whatever the shares say.","example":28},"presenceHigh":{"type":"number","description":"The upper bound of the same interval, in whole percent.","example":53},"zone":{"description":"Where the share sits once the sample's uncertainty is allowed for — a measured condition, never a verdict. `named_in_a_quarter_or_more_of_answers`: the lower bound is above 25%, the company is part of the market as the models draw it. `named_in_under_a_tenth_of_answers`: the upper bound is below 10%. `share_not_yet_separable`: the interval straddles a line — recommended sometimes, but too few answers to say whether that is often or rarely. Report the condition in those words; never 'irrelevant', 'tail' or 'core' as a judgement.","example":"named_in_a_quarter_or_more_of_answers","allOf":[{"$ref":"#/components/schemas/AiMarketZone"}]}},"required":["answersNaming","answersReceived","presence","presenceLow","presenceHigh","zone"]},"AiVisibilityTrendReadingResponse":{"type":"object","properties":{"date":{"type":"string","description":"When the published check this reading is from completed (ISO-8601).","example":"2026-08-30T09:30:00.000Z"},"checkId":{"type":"string","description":"That check's id — pass it to the check-detail route to read the answers behind the reading.","example":"507f1f77bcf86cd799439012"},"presence":{"description":"How often the company was recommended in that check's window, with its range and zone.","allOf":[{"$ref":"#/components/schemas/AiVisibilityTrendPresenceResponse"}]},"rank":{"type":"number","description":"The company's rank by how often it was recommended on that check's map: one plus the companies recommended more often, ties sharing a rank. Null where the company was not on that map (no answer in the window named it), and null under a `provider` filter — a rank exists only across every model.","example":3,"nullable":true},"score":{"type":"number","description":"The company's AI Visibility Score on that check (0-100). The score counts only the top 5 positions in an answer, evenly spaced — first place the most, the last scoring position the least — and nothing below them. It is a reading of WHERE a brand lands when it is named, never of who is ahead: a standing claim — \"you lead\", \"you trail\", \"the leader is X\" — rests on how often each brand is named (presence on the market map, or mentionRate within one check) and never on this score, which can favour a brand named half as often. A score of 0 for a brand the answers did name means it sat only in the tail of AI recommendations — below the top 5, or too seldom inside it for the average to register. Null where the check's rows carry none — no counted answer on that check named the company; the customer's own is present on every check — and null under a `provider` filter, since the score is one figure across every model.","example":41,"nullable":true}},"required":["date","checkId","presence","rank","score"]},"AiVisibilityTrendSeriesPointResponse":{"type":"object","properties":{"date":{"type":"string","description":"When the published check completed (ISO-8601).","example":"2026-08-20T09:30:00.000Z"},"presence":{"type":"number","description":"The share of answers recommending the company in that check's window, in whole percent — or null where this scope was not measured on that check (the model returned no usable answer in the window). Null is a break in the line, never a zero; a measured zero ships as 0.","example":27,"nullable":true},"presenceLow":{"type":"number","description":"The lower bound of the 95% interval on that share; null with `presence`.","example":17,"nullable":true},"presenceHigh":{"type":"number","description":"The upper bound of the same interval; null with `presence`.","example":40,"nullable":true}},"required":["date","presence","presenceLow","presenceHigh"]},"AiVisibilityTrendCompanyResponse":{"type":"object","properties":{"name":{"type":"string","description":"The company's name as the latest map in the window labels it — a label, not an identity; match companies on `domain`.","example":"Acme"},"domain":{"type":"string","description":"The company's domain — its identity across every check and every surface.","example":"acme.com"},"isOwn":{"type":"boolean","description":"True on the project's own company — the row every other row is read against.","example":false},"isTracked":{"type":"boolean","description":"True when the project tracks this domain as a competitor today. False means the AI models raised it unprompted.","example":true},"enginesBacking":{"type":"array","description":"The models that recommended the company at least once in the latest check's window, in a fixed order. A company named by one model and one named by all of them read the same on the pooled share, so read this before saying a company is named across the market rather than by one model. An empty list means no model named the company on the latest map — a measured absence. ABSENT under a `provider` filter: one model's slice cannot say which models back a company, so the key is left off rather than emptied.","example":["openai","claude"],"items":{"type":"string","enum":["openai","claude","gemini","perplexity","google_ai_overviews"]}},"now":{"description":"The latest measured reading in the window.","allOf":[{"$ref":"#/components/schemas/AiVisibilityTrendReadingResponse"}]},"start":{"description":"The earliest measured reading in the window, when it is a different check from `now`. Null when the window holds a single measured reading — there is no movement to report then, and every `*Change` field is null with it.","nullable":true,"allOf":[{"$ref":"#/components/schemas/AiVisibilityTrendReadingResponse"}]},"presenceChange":{"type":"number","description":"`now.presence.presence` minus `start.presence.presence`, in points of share. Positive means recommended more often now. Null without a `start`.","example":12,"nullable":true},"presenceChangeSeparable":{"type":"boolean","description":"Whether the two readings' ranges separate — `now`'s 95% interval sits wholly above or wholly below `start`'s. True: the change is a settled move and may be reported as one. False: the ranges overlap, so `presenceChange` is the difference between two readings and never 'rose' or 'fell' — say the share now and at the start, and that the ranges overlap. Null without a `start`.","example":true,"nullable":true},"rankChange":{"type":"number","description":"`start.rank` minus `now.rank` — places moved, positive means the company climbed. Null without a rank on both ends, and always null under a `provider` filter.","example":2,"nullable":true},"scoreChange":{"type":"number","description":"`now.score` minus `start.score`. Null without a score on both ends, and always null under a `provider` filter.","example":9,"nullable":true},"series":{"description":"Only with `detail=series`: the company's share check by check, oldest first, at most 12 points spread evenly over the window with the first and the last always included. Read the ends from `start` and `now`; this is for the shape between them.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityTrendSeriesPointResponse"}}},"required":["name","domain","isOwn","isTracked","now","start","presenceChange","presenceChangeSeparable","rankChange","scoreChange"]},"AiVisibilityStandingChangeResponse":{"type":"object","properties":{"date":{"type":"string","description":"When the change was announced (ISO-8601) — the second check the new standing held on.","example":"2026-08-26T09:30:00.000Z"},"from":{"description":"The customer's zone before the change; null when the customer was on no map before — a measured absence, not a failed measurement.","nullable":true,"example":"share_not_yet_separable","allOf":[{"$ref":"#/components/schemas/AiMarketZone"}]},"to":{"description":"The customer's zone after the change — the state that held.","example":"named_in_a_quarter_or_more_of_answers","allOf":[{"$ref":"#/components/schemas/AiMarketZone"}]}},"required":["date","from","to"]},"AiVisibilityIncompleteCycleResponse":{"type":"object","properties":{"date":{"type":"string","description":"When the cycle started (ISO-8601). It has no completion time to report.","example":"2026-03-14T02:00:00.000Z"},"reason":{"type":"string","description":"Why this cycle produced no reading. `incomplete_coverage` — at least one query it asked could not be read — no usable answer came back from it. A query the model was read for and had no answer to show does not count against coverage.","enum":["incomplete_coverage"],"example":"incomplete_coverage"},"measuredAnswers":{"type":"number","description":"Answers that came back usable in this cycle. Not a visibility figure — a cycle can be fully covered and score zero.","example":8},"expectedAnswers":{"type":"number","description":"Answers the cycle asked for: every AI model it queried, against every prompt it ran — read the number from this field; do not recompute it from today's model count, which is not necessarily that cycle's. `expectedAnswers` − `measuredAnswers` is the uncounted total, and it is two facts added together: queries that could not be read, plus queries the model was read for that had no answer to show (`absentAnswers`). Subtract `absentAnswers` from that shortfall to get the number of queries that could not be read; report the two apart, never the total as answers that failed to arrive.","example":12},"absentAnswers":{"type":"number","description":"Queries in this cycle the model was read for and had no answer to show — today, prompts for which Google's results page carried no AI Overview. They sit inside `expectedAnswers` − `measuredAnswers` without being failures: that shortfall minus this figure is the number of queries that could not be read.","example":1}},"required":["date","reason","measuredAnswers","expectedAnswers","absentAnswers"]},"AiVisibilityTrendEventsResponse":{"type":"object","properties":{"standingChanges":{"description":"The customer's own standing on the market map moving to a different zone and holding there — the alerts the customer received, as dated marks. A standing is announced only once it has held for two consecutive published checks, so a one-check blip never appears here. Empty when none in the window.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityStandingChangeResponse"}},"incompleteCycles":{"description":"Monitoring cycles inside the window that produced no reading, because at least one query they asked could not be read. Reported so a gap between two readings is distinguishable from a period when nothing was scheduled. The newest 200 in the window, oldest first — the same end the readings are cut from, so on a long history set `dateFrom`/`dateTo` to keep both on one span. Empty for a window with none — an empty array never means \"we know there were no gaps\".","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityIncompleteCycleResponse"}},"promptsLastChangedAt":{"type":"string","description":"When the project's prompts were last edited (ISO-8601), or null when never. Readings before that date are answers to different questions; do not read a move across it as the market moving.","example":"2026-08-16T14:00:00.000Z","nullable":true}},"required":["standingChanges","incompleteCycles","promptsLastChangedAt"]},"AiVisibilityTrendResponse":{"type":"object","properties":{"window":{"description":"The window the readings were taken over.","allOf":[{"$ref":"#/components/schemas/AiVisibilityTrendWindowResponse"}]},"scope":{"description":"Every AI model at once (`all`), or the one asked for with `provider`. Under one model the readings are that model's own slices of each map, and rank and score are absent.","example":"all","oneOf":[{"type":"string","enum":["all"]},{"type":"string","enum":["openai","claude","gemini","perplexity","google_ai_overviews"]}]},"companies":{"description":"One row per company, in the order of how often each is recommended on the latest map — ties are ties. The project's own company and its tracked competitors are always here; the rest are the most recommended companies in the window, up to 10 rows in all. A company with no measured reading on this scope in the window is left out.","type":"array","items":{"$ref":"#/components/schemas/AiVisibilityTrendCompanyResponse"}},"events":{"description":"What happened on the time axis, as facts — the consumer writes the sentence.","allOf":[{"$ref":"#/components/schemas/AiVisibilityTrendEventsResponse"}]}},"required":["window","scope","companies","events"]},"AiSourcesVerdict":{"type":"string","enum":["recommended_nowhere","named_on_most_core_hosts","missing_from_most_core_hosts"],"description":"The one reading of the customer's standing this check, decided when the summary was built and carried here as stored — a consumer never re-derives it from the numbers. Each value names a CONDITION over fields on this same object, never a rating: `recommended_nowhere` — no answer in the window named the customer (the row on `brands` with `isOwn: true` has `answers.answersNaming` of 0). `named_on_most_core_hosts` — the customer is named on at least half of the core hosts (`funnel.alreadyNamingCustomer × 2` is at least `funnel.coreHosts`, with a core to speak of): a short work list under this value is the finding, because there is little left to win. `missing_from_most_core_hosts` — otherwise: named somewhere in the window, and absent from most of the hosts more than one engine read. State the condition beside the two counts it rests on; it ranks nobody and is not a score."},"AiSourcesCheckIdsResponse":{"type":"object","properties":{"checkIds":{"description":"Check identifiers. Membership; the count is the array's length.","example":["507f1f77bcf86cd799439012"],"type":"array","items":{"type":"string"}}},"required":["checkIds"]},"AiSourcesPerEngineCheckIdsResponse":{"type":"object","properties":{"perplexity":{"description":"Perplexity.","allOf":[{"$ref":"#/components/schemas/AiSourcesCheckIdsResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews.","allOf":[{"$ref":"#/components/schemas/AiSourcesCheckIdsResponse"}]}}},"AiSourcesWindowResponse":{"type":"object","properties":{"checkIds":{"description":"The published checks pooled into this summary — this check first, then the previous published checks, up to 5 in all. Checks, never days: the interval is a schedule setting, so a three-day and a monthly project with the same window are equally trustworthy. Membership; the count is the array's length. A single entry is the first check, and every \"seen in\" figure then reads 1 of 1 — `limits.sentences` says so.","example":["507f1f77bcf86cd799439012","507f1f77bcf86cd799439011"],"type":"array","items":{"type":"string"}},"since":{"type":"string","description":"When the oldest check in the window started (ISO-8601).","example":"2026-08-01T10:00:00.000Z"},"perEngine":{"description":"Per engine, the checks in the window on which that engine received at least one answer — the M of that engine's \"seen in N of M\" on `coreHosts[].seenInChecks`. It moves between checks where an engine showed no answer on a whole check. Keyed by engine. A key is ABSENT for an engine this check did not ask — a check keeps the engine set it was run with — and absent means not measured, never a zero. Never add the engines' page figures together.","allOf":[{"$ref":"#/components/schemas/AiSourcesPerEngineCheckIdsResponse"}]}},"required":["checkIds","since","perEngine"]},"AiSourcesEngineUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false. This object exists only to say the engine produced nothing usable this check.","example":false},"reason":{"type":"string","description":"`no_usable_answer` — every question this engine was asked came back unmeasured, so there is no reading of it this check. Our problem, not the engine's verdict on anyone: report it as 'we could not read this engine this check', never as 'the engine named nobody'. An engine read for every question that showed no answer on any of them is NOT this — that publishes as measured absences.","enum":["no_usable_answer"],"example":"no_usable_answer"}},"required":["available","reason"]},"AiSourcesCountRangeResponse":{"type":"object","properties":{"floor":{"type":"number","description":"The corroborated count. Where a brand's name is also ordinary language, a page counts here only when it corroborates the name — the brand's domain in its text or its links, or its name in the page title. For a brand whose name is unambiguous, `floor` equals `ceiling`.","example":4},"ceiling":{"type":"number","description":"The naive count: every page whose text matches the name, corroborated or not. Never quote it alone for an ambiguous brand; quote the pair as a range.","example":6}},"required":["floor","ceiling"]},"AiSourcesEngine":{"type":"string","enum":["perplexity","google_ai_overviews"],"description":"The OTHER engines that also retrieved this host this check. Empty means only this engine did. Membership, never a count."},"AiSourcesTopHostResponse":{"type":"object","properties":{"host":{"type":"string","description":"The host, lower-case, without `www.`.","example":"g2.com"},"pages":{"type":"number","description":"Distinct pages THIS engine retrieved on this host this check.","example":3},"promptIds":{"description":"The questions whose answers retrieved a page on this host, on this engine. Membership; the count is the array's length.","type":"array","items":{"type":"string"}},"alsoRetrievedBy":{"type":"array","description":"The OTHER engines that also retrieved this host this check. Empty means only this engine did. Membership, never a count.","example":["google_ai_overviews"],"items":{"$ref":"#/components/schemas/AiSourcesEngine"}}},"required":["host","pages","promptIds","alsoRetrievedBy"]},"AiSourcesEngineSummaryResponse":{"type":"object","properties":{"engineDataAvailable":{"description":"Present ONLY when this engine produced nothing usable this check. When present, no other field on this object is, and the card for this engine reads as unmeasured with this reason — never as zeros.","allOf":[{"$ref":"#/components/schemas/AiSourcesEngineUnavailableResponse"}]},"answersAsked":{"type":"number","description":"Questions this engine was asked — the check's own recorded question count, read off the check.","example":8},"answersReceived":{"type":"number","description":"Questions this engine answered. THE denominator for every 'answers' figure on this engine. State the shortfall against `answersAsked` as two facts, never as a ratio.","example":7},"answersAbsent":{"type":"number","description":"Questions the engine was read for and showed no answer to — today, questions Google's results page carried no AI Overview for. Measured absences: not answers, not failures, and in no denominator.","example":1},"answersUnmeasured":{"type":"number","description":"Questions we could not read this engine's answer to. In no denominator. Our problem, never a fact about the question or the engine's view of anyone.","example":0},"answersWithoutRetrieval":{"type":"number","description":"Of `answersReceived`: answers the engine wrote without retrieving any page — it reported no page, and has not said how it answered; say `no pages reported`, never `from memory`. Still answers; they contribute no page.","example":1},"answersNamingCustomer":{"type":"number","description":"Of `answersReceived`: answers that named the customer.","example":3},"answersRetrievingOwnPage":{"type":"number","description":"Of `answersReceived`: answers in which the engine retrieved at least one page on the customer's own site. Being read is not being recommended — compare with `answersNamingCustomer`.","example":4},"pagesRetrieved":{"type":"number","description":"Distinct pages this engine RETRIEVED this check across its answers. Retrieved, never cited: the engine does not say which it leaned on. This engine's number alone — never add it to another engine's.","example":131},"hostsRetrieved":{"type":"number","description":"Distinct hosts among `pagesRetrieved`.","example":58},"hostsOnlyThisEngine":{"type":"number","description":"Of `hostsRetrieved`: hosts no other engine retrieved this check.","example":33},"engineCoreSize":{"type":"number","description":"Hosts this engine retrieved for at least 2 of the questions — this engine's own core, this check. The universe of that engine's \"seen in N of M\" on the core hosts. Small on some engines by nature: quote it beside any \"seen in\" figure.","example":35},"pagesRead":{"type":"number","description":"Of `pagesRetrieved`: pages whose body we read. The universe of `independentPagesNamingCustomer`.","example":118},"pagesUnreadable":{"type":"number","description":"Of `pagesRetrieved`: pages we could not read. Listed, never counted — an unreadable page is never a page the customer is absent from.","example":9},"pagesNotFetched":{"type":"number","description":"Of `pagesRetrieved`: pages whose fetch never reported. Kept apart from `pagesUnreadable` because 'we stopped' and 'we could not read it' are different facts.","example":4},"ownPagesRetrieved":{"type":"number","description":"Of `pagesRetrieved`: pages on the customer's own site, subdomains included. Never counted as independent.","example":6},"independentPagesNamingCustomer":{"description":"Independent pages this engine retrieved whose text names the customer — pages not on the customer's own site — over `pagesRead`. A range: `floor` is the corroborated count, `ceiling` the naive one; equal for an unambiguous name. Named on a page the engine retrieved, not necessarily in the passage it read.","allOf":[{"$ref":"#/components/schemas/AiSourcesCountRangeResponse"}]},"topHosts":{"description":"The hosts this engine leaned on most this check, at most 10, by pages retrieved then host name. Each says which other engines also retrieved it.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesTopHostResponse"}}}},"AiSourcesPerEngineSummaryResponse":{"type":"object","properties":{"perplexity":{"description":"Perplexity's reading of this check.","allOf":[{"$ref":"#/components/schemas/AiSourcesEngineSummaryResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews' reading of this check.","allOf":[{"$ref":"#/components/schemas/AiSourcesEngineSummaryResponse"}]}}},"AiSourcesPromptIntent":{"type":"string","enum":["general_choice","problem_driven","price_constraint","company_size","feature","free_tier","safety","category_compare"],"description":"The question's angle. The intents are fixed across every project — one question per intent — and the token names the angle and nothing else."},"AiSourcesSlotState":{"type":"string","enum":["answered","no_answer_shown","not_measured"],"description":"One of three states, never two. `answered` — the engine answered; every other field on this cell is present. `no_answer_shown` — the engine was read and showed no answer for this question (today: Google's results page carried no AI Overview). Measured; not an answer, not a failure, in no denominator. `not_measured` — we could not read this cell. Not a zero; in no count. Never collapse the middle state into either neighbour: into `not_measured` it discards a fact about the engine's page; into `answered` it divides the customer's number by answers that never existed."},"AiSourcesSlotStateReason":{"type":"string","enum":["no_ai_overview_shown","no_usable_answer","transcription_failed"],"description":"Present iff `state` is not `answered`. For `no_answer_shown`: `no_ai_overview_shown`. For `not_measured`: `no_usable_answer` (we asked and did not end up with an answer we could use — names no actor) or `transcription_failed` (the engine answered and its pages were captured, but the step that reads the companies out of the text failed; the slot enters no count until a recompute repairs it). Copy built on either says WE could not read this cell — never that the question failed, and never a fact about any brand."},"AiSourcesWebSearchResponse":{"type":"object","properties":{"performed":{"type":"boolean","description":"Whether a live search RAN while the engine wrote this answer. `true` records that a search ran, not that the answer rests on what it found. For an engine read off a results page the search is the request, so it is true whenever the page came back.","example":true},"completedSearchCount":{"type":"number","description":"How many searches the engine reports — each engine's own quantity, and NOT comparable across engines: one reports billable requests, another only the query strings it lists. Compare an engine against itself over time; never compare engines with each other and never sum them. `0` beside `performed: false`.","example":3},"reason":{"type":"string","description":"Present iff `performed` is false. `NO_SEARCH_ATTEMPTED` — the search tool was offered and went unused. `SEARCH_FAILED` — retrieval was attempted and did not succeed. `NO_SEARCH_REPORTED` — the answer carries no evidence of a search and the engine discloses no cause. None of these means the engine chose to answer from memory; they record that no retrieval is evidenced, and why is not something the data answers.","enum":["NO_SEARCH_ATTEMPTED","SEARCH_FAILED","NO_SEARCH_REPORTED"],"example":"NO_SEARCH_ATTEMPTED"}},"required":["performed","completedSearchCount"]},"AiSourcesMatrixCellResponse":{"type":"object","properties":{"state":{"description":"One of three states, never two. `answered` — the engine answered; every other field on this cell is present. `no_answer_shown` — the engine was read and showed no answer for this question (today: Google's results page carried no AI Overview). Measured; not an answer, not a failure, in no denominator. `not_measured` — we could not read this cell. Not a zero; in no count. Never collapse the middle state into either neighbour: into `not_measured` it discards a fact about the engine's page; into `answered` it divides the customer's number by answers that never existed.","example":"answered","allOf":[{"$ref":"#/components/schemas/AiSourcesSlotState"}]},"reason":{"description":"Present iff `state` is not `answered`. For `no_answer_shown`: `no_ai_overview_shown`. For `not_measured`: `no_usable_answer` (we asked and did not end up with an answer we could use — names no actor) or `transcription_failed` (the engine answered and its pages were captured, but the step that reads the companies out of the text failed; the slot enters no count until a recompute repairs it). Copy built on either says WE could not read this cell — never that the question failed, and never a fact about any brand.","example":"no_ai_overview_shown","allOf":[{"$ref":"#/components/schemas/AiSourcesSlotStateReason"}]},"named":{"type":"boolean","description":"Whether this answer named the customer. A measured boolean: `false` is 'answered and did not name them'. Independent of `pagesNamingCustomer` — an engine can read pages naming the customer and still not recommend them, and the reverse.","example":false},"companiesNamed":{"type":"number","description":"Companies this answer named. `0` is the answer that explained a concept and recommended nobody — a real finding, not a gap.","example":6},"webSearch":{"description":"The retrieval signal, verbatim from the stored answer. `performed: false` is the answer the engine's own record says it wrote without a search — kept as it is, never retried away.","allOf":[{"$ref":"#/components/schemas/AiSourcesWebSearchResponse"}]},"pagesRetrieved":{"type":"number","description":"Distinct pages the engine RETRIEVED for this answer. `0` beside `answered` is the 'search did not run' cell: an answer received, contributing no page.","example":14},"pagesRead":{"type":"number","description":"Of `pagesRetrieved`: pages whose body we read. The universe of `pagesNamingCustomer`.","example":12},"pagesUnreadable":{"type":"number","description":"Of `pagesRetrieved`: pages we could not read. Listed, not counted.","example":1},"pagesNotFetched":{"type":"number","description":"Of `pagesRetrieved`: pages whose fetch never reported.","example":1},"pagesNamingCustomer":{"description":"Pages retrieved for this answer whose text names the customer — independent pages only, the customer's own site excluded — over `pagesRead`. A range; see `floor` / `ceiling`.","allOf":[{"$ref":"#/components/schemas/AiSourcesCountRangeResponse"}]},"ownPagesRetrieved":{"type":"number","description":"Pages retrieved for this answer that are on the customer's own site.","example":1}},"required":["state"]},"AiSourcesPerEngineCellResponse":{"type":"object","properties":{"perplexity":{"description":"Perplexity's cell for this question.","allOf":[{"$ref":"#/components/schemas/AiSourcesMatrixCellResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews' cell for this question.","allOf":[{"$ref":"#/components/schemas/AiSourcesMatrixCellResponse"}]}}},"AiSourcesMatrixRowResponse":{"type":"object","properties":{"promptId":{"type":"string","description":"Stable identifier of the question, kept across edits of its wording. The handle to join this row with `answers[]`, `coreHosts[].promptIds` and `pages[].promptIds`.","example":"7f3c1a2e-9d44-4c8b-9f0e-2b6a5c9d7e10"},"promptText":{"type":"string","description":"The question as it was asked — one of the project's own buying questions.","example":"Which email warm-up tools are safe to use with a Google Workspace domain?"},"intent":{"description":"The question's angle. The intents are fixed across every project — one question per intent — and the token names the angle and nothing else.","example":"safety","allOf":[{"$ref":"#/components/schemas/AiSourcesPromptIntent"}]},"cells":{"description":"One cell per engine this check asked. Keyed by engine. A key is ABSENT for an engine this check did not ask — a check keeps the engine set it was run with — and absent means not measured, never a zero. Never add the engines' page figures together.","allOf":[{"$ref":"#/components/schemas/AiSourcesPerEngineCellResponse"}]}},"required":["promptId","promptText","intent","cells"]},"AiSourcesBrandOrigin":{"type":"string","enum":["customer","tracked_competitor","named_by_engines"],"description":"How the brand came to be listed: the customer, a tracked competitor, or a company an engine's answer named in the window that is not on the competitor list."},"AiSourcesAnswerShareResponse":{"type":"object","properties":{"answersNaming":{"type":"number","description":"Answers in this scope that named the brand — counted once per answer, matched on the brand's domain, never on its wording.","example":5},"answersReceived":{"type":"number","description":"The universe `answersNaming` is drawn from: answers received in this scope. Questions that produced no answer, or that the engine was read for and had nothing to show, are not in it. Quote it whenever you quote the share.","example":16},"presence":{"type":"number","description":"`answersNaming / answersReceived × 100`, whole percent. A share of answers received — never of questions asked, and never a probability. Always read with `presenceLow` / `presenceHigh` beside it.","example":31},"presenceLow":{"type":"number","description":"Lower bound of a 95% interval on `presence`, same percent scale. Two brands whose ranges overlap are NOT ordered, whatever their rank says.","example":14},"presenceHigh":{"type":"number","description":"Upper bound of the same interval. See `presenceLow`.","example":56}},"required":["answersNaming","answersReceived","presence","presenceLow","presenceHigh"]},"AiSourcesBrandEngineReadingResponse":{"type":"object","properties":{"answers":{"description":"Answers from THIS engine in the window that named the brand, over the answers it received there, with the interval.","allOf":[{"$ref":"#/components/schemas/AiSourcesAnswerShareResponse"}]},"independentPagesNaming":{"description":"Independent pages this engine retrieved THIS CHECK whose text names the brand — pages not on the brand's own site — over `pagesRead` on this same object. A range. This engine's number alone: never add it to another engine's.","allOf":[{"$ref":"#/components/schemas/AiSourcesCountRangeResponse"}]},"pagesRead":{"type":"number","description":"The universe of `independentPagesNaming`: this engine's read pages this check.","example":118},"ownPagesRetrieved":{"type":"number","description":"Pages this engine retrieved this check that are on the brand's own site.","example":2}},"required":["answers","independentPagesNaming","pagesRead","ownPagesRetrieved"]},"AiSourcesPerEngineBrandReadingResponse":{"type":"object","properties":{"perplexity":{"description":"Perplexity's reading of this brand.","allOf":[{"$ref":"#/components/schemas/AiSourcesBrandEngineReadingResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews' reading of this brand.","allOf":[{"$ref":"#/components/schemas/AiSourcesBrandEngineReadingResponse"}]}}},"AiSourcesBrandRowResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Brand domain — the identity every count is matched on.","example":"competitor.com"},"name":{"type":"string","description":"Brand name as the engines most often wrote it. Decoration: it can change between checks while the domain does not. Never match or join on it.","example":"Competitor Inc"},"isOwn":{"type":"boolean","description":"Whether this is the customer's own row. The customer's row is ALWAYS present, at zero when nothing named them — that zero is a measured finding.","example":false},"origin":{"description":"How the brand came to be listed: the customer, a tracked competitor, or a company an engine's answer named in the window that is not on the competitor list.","example":"tracked_competitor","allOf":[{"$ref":"#/components/schemas/AiSourcesBrandOrigin"}]},"ambiguous":{"type":"boolean","description":"The name is ordinary language (every word of it is a common word or a word of the category), so every page count for this brand is a range with an open ceiling: quote `floor` and `ceiling` together, never one alone. Inferred, and fails closed.","example":false},"rankByPresence":{"type":"number","description":"Rank by how often the brand is named in answers: one plus the number of brands named more often. Ties SHARE a rank — never break one. Presence, never position: nothing on this surface records how high a brand sat.","example":2},"answers":{"description":"Answers in the window that named the brand, pooled across engines as a vote, with the interval. Pooling ANSWERS is legitimate; `perEngine` beside it says which engines back the row, because a brand named by one engine and a brand named by both look identical on the pooled figure.","allOf":[{"$ref":"#/components/schemas/AiSourcesAnswerShareResponse"}]},"answersRetrievingOwnSite":{"description":"Answers in the window in which an engine retrieved at least one page on the brand's own site, pooled the same way. Being read is not being recommended — compare with `answers`.","allOf":[{"$ref":"#/components/schemas/AiSourcesAnswerShareResponse"}]},"enginesNaming":{"type":"array","description":"The engines on which at least one answer in the window named the brand. Membership, never a count.","example":["perplexity"],"items":{"$ref":"#/components/schemas/AiSourcesEngine"}},"perEngine":{"description":"This brand on each engine. A key is absent for an engine that received no answer in the window. Page counts are per engine and are never summed across keys. Keyed by engine. A key is ABSENT for an engine this check did not ask — a check keeps the engine set it was run with — and absent means not measured, never a zero. Never add the engines' page figures together.","allOf":[{"$ref":"#/components/schemas/AiSourcesPerEngineBrandReadingResponse"}]}},"required":["domain","name","isOwn","origin","ambiguous","rankByPresence","answers","answersRetrievingOwnSite","enginesNaming","perEngine"]},"AiSourcesOverlapEngineResponse":{"type":"object","properties":{"hostsRetrieved":{"type":"number","description":"Distinct hosts this engine retrieved this check.","example":58},"hostsOnlyThisEngine":{"type":"number","description":"Of `hostsRetrieved`: hosts no other engine retrieved this check.","example":33}},"required":["hostsRetrieved","hostsOnlyThisEngine"]},"AiSourcesPerEngineOverlapResponse":{"type":"object","properties":{"perplexity":{"description":"Perplexity.","allOf":[{"$ref":"#/components/schemas/AiSourcesOverlapEngineResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews.","allOf":[{"$ref":"#/components/schemas/AiSourcesOverlapEngineResponse"}]}}},"AiSourcesOverlapResponse":{"type":"object","properties":{"hostsInCore":{"type":"number","description":"Hosts retrieved by at least 2 engines this check — the core, and the ONE cross-engine object on this surface. Render the overlap as 'only one engine / both / only the other' with absolute counts; there is deliberately no union total, because no engine produced that list.","example":25},"perEngine":{"description":"Per engine this check asked: hosts retrieved, and how many of them no other engine retrieved. Keyed by engine. A key is ABSENT for an engine this check did not ask — a check keeps the engine set it was run with — and absent means not measured, never a zero. Never add the engines' page figures together.","allOf":[{"$ref":"#/components/schemas/AiSourcesPerEngineOverlapResponse"}]}},"required":["hostsInCore","perEngine"]},"AiSourcesFunnelResponse":{"type":"object","properties":{"coreHosts":{"type":"number","description":"Hosts retrieved by at least 2 engines this check — the start of the narrowing, and the universe every other field here is a filter over. Equals `alreadyNamingCustomer + missing + unreadable`, and equals `coreHosts.length`.","example":25},"alreadyNamingCustomer":{"type":"number","description":"Of `coreHosts`: hosts on which at least one page names the customer. Already won — never a target.","example":19},"unreadable":{"type":"number","description":"Of `coreHosts`: hosts on which no page could be read and none names the customer from its title or passage. Listed, never counted as missing — an unread page is never a page the customer is absent from.","example":2},"missing":{"type":"number","description":"Of `coreHosts`: hosts on which at least one page was READ and none names the customer — the pages the customer is genuinely missing from. Equals `missingPublishers + missingCompetitorOwned`. Small on a market leader: 'already on 19 of the 25 hosts more than one engine read' is the finding, not an empty state.","example":4},"missingPublishers":{"type":"number","description":"Of `missing`: third-party hosts — publishers, communities, review sites. The work list: addressable by outreach.","example":3},"missingCompetitorOwned":{"type":"number","description":"Of `missing`: a competitor's own site. Not won by outreach; listed so the narrowing is complete.","example":1}},"required":["coreHosts","alreadyNamingCustomer","unreadable","missing","missingPublishers","missingCompetitorOwned"]},"AiSourcesCoreHostStatus":{"type":"string","enum":["already_named","missing","unreadable"],"description":"The host's standing for the customer. `already_named` — at least one page on it names the customer: won. `missing` — at least one page was read and none names the customer: the work list, and the only status that supports 'get onto this'. `unreadable` — no page on the host could be read and none names the customer from its title or passage: listed, not counted, never a target."},"AiSourcesHostOwnership":{"type":"string","enum":["third_party","competitor_owned"],"description":"`third_party` — nobody on the leaderboard owns it: a publisher, a community, a review site; addressable. `competitor_owned` — a competitor's own site; not won by outreach. The customer's own hosts are never on this list."},"AiSourcesHostKind":{"type":"string","enum":["review_site","community","video","publisher"],"description":"What kind of site the host is, from a dated table of known hosts; anything not in the table is a `publisher`. The kind chooses the action hint; it never changes a count."},"AiSourcesExplanationResponse":{"type":"object","properties":{"code":{"type":"string","description":"Stable identifier for this sentence. Branch on it if you need to; never substitute your own wording for it.","example":"sources_retrieved_not_cited"},"text":{"type":"string","description":"The sentence to report, in the words every CompetLab surface uses. Render it VERBATIM — do not paraphrase, shorten, or compose your own from the code. It names no row's numbers on purpose; the fields beside it carry those.","example":"Retrieved, never cited: these are the pages the engine pulled back while answering; which of them it leaned on is not something the engines disclose."}},"required":["code","text"]},"AiSourcesPageReadState":{"type":"string","enum":["read","unreadable","not_fetched"],"description":"`read` — the body was fetched and read. `unreadable` — we could not read it; `unreadableReason` says what was observed. `not_fetched` — no fetch ever reported for it. Kept apart from `unreadable` because 'we stopped' and 'we could not read it' are different facts."},"AiSourcesPageUnreadableReason":{"type":"string","enum":["bot_protection","host_blocked_recently","text_too_short","not_found_page","consent_wall","unreachable","timeout","fetch_failed","unresolved"],"description":"Present iff `readState` is `unreadable`. Each names what was OBSERVED about the fetch, never a fact about the page's content: `bot_protection` — the site refused an automated reader; `host_blocked_recently` — not fetched, because the host refused us within the last month; `text_too_short` — a body arrived with too few readable characters; `not_found_page` — the server answered with a page that says the resource does not exist; `consent_wall` — the server answered with a consent interstitial in place of the page; `unreachable` — the host could not be reached; `timeout` — the page did not finish loading in time; `fetch_failed` — any other failure to obtain a body; `unresolved` — the engine returned a redirect we could not resolve to a page."},"AiSourcesPageTextSource":{"type":"string","enum":["body","snippet_only","none"],"description":"What text this page was judged on. `body` — the fetched page plus the title and passage the engine handed back. `snippet_only` — the title and passage alone, for a page we could not read. `none` — a page with neither."},"AiSourcesPageNaming":{"type":"string","enum":["named","named_uncorroborated","not_named"],"description":"Whether this page's text names the customer. `named` — it does, and where the name is ordinary language the page corroborates it. `named_uncorroborated` — the name matched but nothing corroborates it; reachable only for an ambiguous brand, and in the ceiling of every range rather than the floor. `not_named` — the page was READ and does not name the customer: the ONLY value that supports 'get onto this page'. `null` — the page was not read (unreadable or not fetched), so nothing can be said. An unread page can still be `named` from the title and passage the engine handed back: an unread page can prove presence, never absence."},"AiSourcesCheckPageResponse":{"type":"object","properties":{"key":{"type":"string","description":"The page's identity: the canonical form of its address — lower-case host without `www.`, no scheme, tracking parameters removed, no fragment. One page listed under two addresses is read once and counted once.","example":"g2.com/categories/email-warm-up"},"url":{"type":"string","description":"The address as an engine returned it.","example":"https://www.g2.com/categories/email-warm-up"},"host":{"type":"string","description":"The host, lower-case, without `www.`.","example":"g2.com"},"title":{"type":"string","description":"The page's title — its own when we read it, else the title an engine reported. Absent when neither exists.","example":"Best Email Warm-Up Software"},"engines":{"type":"array","description":"The engines that retrieved this page this check. Membership, never a count; its length is how many engines read it.","example":["perplexity","google_ai_overviews"],"items":{"$ref":"#/components/schemas/AiSourcesEngine"}},"promptIds":{"description":"The questions whose answers retrieved this page, on any engine. Join on `matrix[].promptId`.","type":"array","items":{"type":"string"}},"ownedBy":{"type":"string","description":"The domain of the brand whose own site this page is on, when it is on one — the customer's or a competitor's. Absent for a third-party page. A page on a brand's own site is never an independent source naming that brand.","example":"competitor.com"},"readState":{"description":"`read` — the body was fetched and read. `unreadable` — we could not read it; `unreadableReason` says what was observed. `not_fetched` — no fetch ever reported for it. Kept apart from `unreadable` because 'we stopped' and 'we could not read it' are different facts.","example":"read","allOf":[{"$ref":"#/components/schemas/AiSourcesPageReadState"}]},"unreadableReason":{"description":"Present iff `readState` is `unreadable`. Each names what was OBSERVED about the fetch, never a fact about the page's content: `bot_protection` — the site refused an automated reader; `host_blocked_recently` — not fetched, because the host refused us within the last month; `text_too_short` — a body arrived with too few readable characters; `not_found_page` — the server answered with a page that says the resource does not exist; `consent_wall` — the server answered with a consent interstitial in place of the page; `unreachable` — the host could not be reached; `timeout` — the page did not finish loading in time; `fetch_failed` — any other failure to obtain a body; `unresolved` — the engine returned a redirect we could not resolve to a page.","example":"bot_protection","allOf":[{"$ref":"#/components/schemas/AiSourcesPageUnreadableReason"}]},"fetchedAt":{"type":"string","description":"When this page's verdict was taken (ISO-8601) — the age of the evidence. A page is re-read after a month. Absent when not fetched.","example":"2026-09-04T10:00:00.000Z"},"textSource":{"description":"What text this page was judged on. `body` — the fetched page plus the title and passage the engine handed back. `snippet_only` — the title and passage alone, for a page we could not read. `none` — a page with neither.","example":"body","allOf":[{"$ref":"#/components/schemas/AiSourcesPageTextSource"}]},"namesCustomer":{"description":"Whether this page's text names the customer. `named` — it does, and where the name is ordinary language the page corroborates it. `named_uncorroborated` — the name matched but nothing corroborates it; reachable only for an ambiguous brand, and in the ceiling of every range rather than the floor. `not_named` — the page was READ and does not name the customer: the ONLY value that supports 'get onto this page'. `null` — the page was not read (unreadable or not fetched), so nothing can be said. An unread page can still be `named` from the title and passage the engine handed back: an unread page can prove presence, never absence.","nullable":true,"example":"not_named","allOf":[{"$ref":"#/components/schemas/AiSourcesPageNaming"}]},"brandsNamed":{"description":"Domains of the OTHER brands on the leaderboard that this page's text names. Membership; the count is the array's length. Empty is measured: we looked and found none.","example":["competitor.com","rival.io"],"type":"array","items":{"type":"string"}},"excerpt":{"type":"string","description":"A short passage around the first mention of the customer where named, else of the first other brand named — at most 240 characters. Third-party text, quoted as evidence. Absent where no brand was found in the text.","example":"…the most reliable options this year are Competitor Inc and Rival…"}},"required":["key","url","host","engines","promptIds","readState","textSource","namesCustomer","brandsNamed"]},"AiSourcesCoreHostResponse":{"type":"object","properties":{"host":{"type":"string","description":"The host, lower-case, without `www.`.","example":"g2.com"},"status":{"description":"The host's standing for the customer. `already_named` — at least one page on it names the customer: won. `missing` — at least one page was read and none names the customer: the work list, and the only status that supports 'get onto this'. `unreadable` — no page on the host could be read and none names the customer from its title or passage: listed, not counted, never a target.","example":"missing","allOf":[{"$ref":"#/components/schemas/AiSourcesCoreHostStatus"}]},"ownership":{"description":"`third_party` — nobody on the leaderboard owns it: a publisher, a community, a review site; addressable. `competitor_owned` — a competitor's own site; not won by outreach. The customer's own hosts are never on this list.","example":"third_party","allOf":[{"$ref":"#/components/schemas/AiSourcesHostOwnership"}]},"ownedBy":{"type":"string","description":"The competitor's domain, present iff `ownership` is `competitor_owned`.","example":"competitor.com"},"kind":{"description":"What kind of site the host is, from a dated table of known hosts; anything not in the table is a `publisher`. The kind chooses the action hint; it never changes a count.","example":"review_site","allOf":[{"$ref":"#/components/schemas/AiSourcesHostKind"}]},"kindCheckedAt":{"type":"string","description":"Present iff the kind came from the table: the date (YYYY-MM-DD) a person last checked what this host is. The kind was true on that date; it is not a claim about today.","example":"2026-09-04"},"actionHint":{"description":"What the customer can do about this host, as a stable code with its sentence beside it. Render `text` verbatim; it is identical for every host in the same state and names no host's numbers. `pitch_publisher` — a third-party page we read that does not name the customer. `claim_review_profile` — a review site, whether or not we could read it. `join_community` — a community. `not_addressable_by_text` — a video. `check_manually` — we could not read the page: open it and check. `competitor_owned` — not a target. `already_named` — nothing to do.","example":{"code":"pitch_publisher","text":"Pitch an update to this page: it names your competitors and not you."},"allOf":[{"$ref":"#/components/schemas/AiSourcesExplanationResponse"}]},"engines":{"type":"array","description":"The engines that retrieved a page on this host this check. Its length is at least 2 — that is what makes the host core. Membership, never a count.","example":["perplexity","google_ai_overviews"],"items":{"$ref":"#/components/schemas/AiSourcesEngine"}},"promptIds":{"description":"The questions whose answers retrieved a page on this host, on any engine. Membership; its length is 'N of the project's questions'.","type":"array","items":{"type":"string"}},"seenInChecks":{"description":"Per engine, the checks in the window in which this host was in THAT engine's OWN core — the hosts it retrieved on several of the questions — \"seen in N of M\": N is that engine's array length here, M is `window.perEngine[engine].checkIds.length`. A key is absent for an engine that never had this host in its own core, and absence is never a zero of anything. This is a different core from the row's: a host is on `coreHosts` for being retrieved by two or more engines at any question count, so a host each engine retrieved once is core by overlap and core to neither alone, and carries an empty object here — correct, and read as \"in no engine's own core\", never as 0 of 1. State N and M only for the engines present; never add the engines together.","allOf":[{"$ref":"#/components/schemas/AiSourcesPerEngineCheckIdsResponse"}]},"brandsNamed":{"description":"Domains of the other leaderboard brands named on this host's pages. Membership; the count is the array's length.","example":["competitor.com"],"type":"array","items":{"type":"string"}},"pages":{"description":"This host's pages this check, each with its own read state and naming. A host is `missing` when at least one of these was read and none is `named`.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesCheckPageResponse"}}},"required":["host","status","ownership","kind","actionHint","engines","promptIds","seenInChecks","brandsNamed","pages"]},"AiSourcesOwnPageRefResponse":{"type":"object","properties":{"key":{"type":"string","description":"The page's identity — see `pages[].key`.","example":"mycompany.com/pricing"},"url":{"type":"string","description":"The address as the engine returned it.","example":"https://mycompany.com/pricing"},"title":{"type":"string","description":"The page's title, when known.","example":"Pricing — My Company"}},"required":["key","url"]},"AiSourcesOwnPageRetrievalResponse":{"type":"object","properties":{"engine":{"description":"The engine that opened the customer's site for this answer.","example":"perplexity","allOf":[{"$ref":"#/components/schemas/AiSourcesEngine"}]},"promptId":{"type":"string","description":"The question this answer was for. Join on `matrix[].promptId`.","example":"7f3c1a2e-9d44-4c8b-9f0e-2b6a5c9d7e10"},"pages":{"description":"The customer's own pages the engine retrieved for this answer.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesOwnPageRefResponse"}},"companiesNamed":{"description":"Domains the answer then named, in order of first mention. The evidence and the recommendation side by side: it opened the customer's page, then recommended these.","example":["competitor.com","rival.io"],"type":"array","items":{"type":"string"}},"namedCustomer":{"type":"boolean","description":"Whether the customer was among them.","example":false}},"required":["engine","promptId","pages","companiesNamed","namedCustomer"]},"AiSourcesUnreadableByReasonResponse":{"type":"object","properties":{"bot_protection":{"type":"number","description":"Pages a site refused to an automated reader.","example":5},"host_blocked_recently":{"type":"number","description":"Pages recorded without a request, because their host refused us within the last month.","example":2},"text_too_short":{"type":"number","description":"Pages whose body carried too few readable characters.","example":1},"not_found_page":{"type":"number","description":"Pages the server answered with a page saying the resource does not exist. Listed, never counted.","example":0},"consent_wall":{"type":"number","description":"Pages the server answered with a consent interstitial in place of the page. Listed, never counted.","example":0},"unreachable":{"type":"number","description":"Pages whose host could not be reached.","example":0},"timeout":{"type":"number","description":"Pages that did not finish loading in time.","example":1},"fetch_failed":{"type":"number","description":"Pages that failed to fetch for any other reason.","example":0},"unresolved":{"type":"number","description":"Redirect wrappers an engine returned that we could not resolve to a page.","example":0}}},"AiSourcesTextSourceMixResponse":{"type":"object","properties":{"body":{"type":"number","description":"Pages judged on the fetched body plus the engine's title and passage.","example":140},"snippet_only":{"type":"number","description":"Pages judged on the engine's title and passage alone, because the body could not be read.","example":9},"none":{"type":"number","description":"Pages with neither.","example":4}},"required":["body","snippet_only","none"]},"AiSourcesLimitsResponse":{"type":"object","properties":{"pagesRetrieved":{"type":"number","description":"Distinct pages retrieved this check, across engines. A fact about the FETCH STAGE — one stage over every engine's pages — and never a headline: it is not a pooled page figure and must not be quoted as 'the pages the engines read'. Per-engine page counts are under `perEngine`.","example":153},"pagesRead":{"type":"number","description":"Of `pagesRetrieved`: pages whose body we read.","example":140},"pagesUnreadable":{"type":"number","description":"Of `pagesRetrieved`: pages we could not read. `unreadableByReason` breaks it down.","example":9},"pagesNotFetched":{"type":"number","description":"Of `pagesRetrieved`: pages whose fetch never reported.","example":4},"pagesTruncated":{"type":"number","description":"Of `pagesRead`: pages whose text was cut at the length cap before matching.","example":3},"unreadableByReason":{"description":"`pagesUnreadable` by reason. A reason with no page is absent.","allOf":[{"$ref":"#/components/schemas/AiSourcesUnreadableByReasonResponse"}]},"textSourceMix":{"description":"What text each page was judged on, over `pagesRetrieved`.","allOf":[{"$ref":"#/components/schemas/AiSourcesTextSourceMixResponse"}]},"sentences":{"description":"The standing sentences under every number here, as payload. Each that applies to this check is included; the app, this API and the MCP tools render them VERBATIM and compose none of their own. Among them: retrieved is not cited; named on a page the engine retrieved, not necessarily in the passage it read; a floor for an ambiguous name; per engine, never pooled; an unread page is listed, not counted; a brand's own pages are never independent; and, on a first check, that every 'seen in' reads 1 of 1.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesExplanationResponse"}}},"required":["pagesRetrieved","pagesRead","pagesUnreadable","pagesNotFetched","pagesTruncated","unreadableByReason","textSourceMix","sentences"]},"AiSourcesSummaryResponse":{"type":"object","properties":{"customerDomain":{"type":"string","description":"The customer's domain, normalised.","example":"mycompany.com"},"verdict":{"description":"The one reading of the customer's standing this check, decided when the summary was built and carried here as stored — a consumer never re-derives it from the numbers. Each value names a CONDITION over fields on this same object, never a rating: `recommended_nowhere` — no answer in the window named the customer (the row on `brands` with `isOwn: true` has `answers.answersNaming` of 0). `named_on_most_core_hosts` — the customer is named on at least half of the core hosts (`funnel.alreadyNamingCustomer × 2` is at least `funnel.coreHosts`, with a core to speak of): a short work list under this value is the finding, because there is little left to win. `missing_from_most_core_hosts` — otherwise: named somewhere in the window, and absent from most of the hosts more than one engine read. State the condition beside the two counts it rests on; it ranks nobody and is not a score.","example":"missing_from_most_core_hosts","allOf":[{"$ref":"#/components/schemas/AiSourcesVerdict"}]},"window":{"description":"The checks this summary pools over, and per engine the checks that engine answered on.","allOf":[{"$ref":"#/components/schemas/AiSourcesWindowResponse"}]},"perEngine":{"description":"One reading per engine this check asked — Perplexity and Google AI Overviews today. Every number under a key is that engine's alone. Keyed by engine. A key is ABSENT for an engine this check did not ask — a check keeps the engine set it was run with — and absent means not measured, never a zero. Never add the engines' page figures together. Nothing on this surface is a citation count, and no field sums the engines' pages.","allOf":[{"$ref":"#/components/schemas/AiSourcesPerEngineSummaryResponse"}]},"matrix":{"description":"One row per question the check asked, in question order, with one cell per engine. Two independent facts sit in an answered cell — whether the answer named the customer, and how many retrieved pages name them — and neither is derived from the other.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesMatrixRowResponse"}},"brands":{"description":"Who else this market names: the customer, the tracked competitors, and every company an engine named in the window. In the ORDER TO RENDER — answers naming desc, then domain — and never re-sorted by anything positional. The customer's row is always present; read `isOwn` to find it. Ties share a `rankByPresence`.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesBrandRowResponse"}},"overlap":{"description":"How the engines' host sets overlap this check — the one cross-engine object.","allOf":[{"$ref":"#/components/schemas/AiSourcesOverlapResponse"}]},"funnel":{"description":"The narrowing from 'hosts more than one engine read' to 'hosts the customer is genuinely missing from', in four counts that are each a filter over `coreHosts`. Show the narrowing, not only its end: on a market leader the end is small because most of the core already names them.","allOf":[{"$ref":"#/components/schemas/AiSourcesFunnelResponse"}]},"coreHosts":{"description":"Every core host with its standing — the work list (`missing`), the won list (`already_named`) and the hosts we could not read (`unreadable`) in ONE array, in the ORDER TO RENDER: the work list first (third-party before competitor-owned), then unreadable, then already named. `status` is the column that separates them. Never tell a customer to get onto a host whose status is not `missing`.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesCoreHostResponse"}},"ownPageRetrievals":{"description":"Every answer in which an engine opened one of the customer's own pages, with who it then recommended. Empty is measured: no engine retrieved a page of theirs this check.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesOwnPageRetrievalResponse"}},"pages":{"description":"Every page retrieved this check by any engine, with its read state and naming — the pages-read list. Each says which engines retrieved it; the list itself is a fetch-stage inventory, not a per-engine count, so never quote its length as an engine's number.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesCheckPageResponse"}},"limits":{"description":"What the numbers rest on, and the sentences to render beside them.","allOf":[{"$ref":"#/components/schemas/AiSourcesLimitsResponse"}]}},"required":["customerDomain","verdict","window","perEngine","matrix","brands","overlap","funnel","coreHosts","ownPageRetrievals","pages","limits"]},"AiSourcesLatestCheckUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false. This object exists only to say the most recent check produced nothing publishable.","example":false},"reason":{"type":"string","description":"`no_usable_answer_from_any_engine` — every engine the check asked came back with no usable answer, so nothing was measured and the check was abandoned. Our problem: it never locks the customer out, a new check runs automatically, and the caller may trigger one. A check where ONE engine came back empty is not this — it publishes with that engine marked unmeasured. `page_stage_failed` — the engines answered, but the stage that reads the retrieved pages could not be closed, so no summary was built and the check was abandoned. Ours, never the engines' or the customer's; a new check runs automatically. A page stage that merely ran out of time is not this: it closes with the pages it has, the rest recorded as not fetched, and the check publishes.","enum":["no_usable_answer_from_any_engine","page_stage_failed"],"example":"no_usable_answer_from_any_engine"},"enginesAsked":{"type":"array","description":"The engines that check asked, read off the check. Membership, never a count.","example":["perplexity","google_ai_overviews"],"items":{"$ref":"#/components/schemas/AiSourcesEngine"}}},"required":["available","reason","enginesAsked"]},"AiSourcesAnswerLocaleResponse":{"type":"object","properties":{"locationName":{"type":"string","description":"The country the question was asked from, as the engine names its markets.","example":"United States"},"languageName":{"type":"string","description":"The language the question was asked in, as the engine names it.","example":"English"}},"required":["locationName","languageName"]},"AiSourcesAnswerCompanyResponse":{"type":"object","properties":{"rank":{"type":"number","description":"Position among the companies this answer named — 1 means named first. The ORDER OF FIRST MENTION in `answerText`, computed by CompetLab from the text: the engine assigned no position, so never report it as a rank the engine gave. Stable under filtering.","example":1},"name":{"type":"string","description":"The company's name as the answer wrote it.","example":"Competitor Inc"},"domain":{"type":"string","description":"The company's primary domain, normalised — the identity.","example":"competitor.com"}},"required":["rank","name","domain"]},"AiSourcesAnswerSourceResponse":{"type":"object","properties":{"url":{"type":"string","description":"Address of the retrieved page, after any redirect the engine returned was resolved.","example":"https://www.g2.com/categories/email-warm-up"},"domain":{"type":"string","description":"Domain of the retrieved page.","example":"g2.com"},"title":{"type":"string","description":"Title of the page as the engine reported it. Empty when the engine reported none.","example":"Best Email Warm-Up Software"},"snippet":{"type":"string","description":"The passage the engine handed back for this page beside its answer, verbatim. Present only where the engine reports one; absent means the engine reports none, never that the page has no text.","example":"Warm-up tools gradually increase sending volume so that mailbox providers…"},"publishedAt":{"type":"string","description":"The page's publication date as the engine reports it, verbatim — the engine's string, not a measurement of ours. Present only where the engine reports one.","example":"2026-05-12"},"resolvedFrom":{"type":"string","description":"The address the engine actually returned, when `url` is the page it resolved to. Absent on a direct URL.","example":"https://www.google.com/goto?url=…"}},"required":["url","domain","title"]},"AiSourcesAnswerResponse":{"type":"object","properties":{"engine":{"description":"Which engine produced this answer.","example":"perplexity","allOf":[{"$ref":"#/components/schemas/AiSourcesEngine"}]},"promptIndex":{"type":"number","description":"Zero-based position of the question in this check's question list, as the engine's answers are stored. The value the `promptIndex` filter matches. Stable under filtering. Use `promptId` to join this row with the summary's matrix and pages.","example":0},"promptId":{"type":"string","description":"Stable identifier of the question, kept across edits of its wording.","example":"7f3c1a2e-9d44-4c8b-9f0e-2b6a5c9d7e10"},"promptText":{"type":"string","description":"The question as it was sent — one of the project's own buying questions.","example":"Which email warm-up tools are safe to use with a Google Workspace domain?"},"intent":{"description":"The question's angle. Fixed across every project; the token names the angle and nothing else.","example":"safety","allOf":[{"$ref":"#/components/schemas/AiSourcesPromptIntent"}]},"askedIn":{"description":"Where the engine was asked. Both engines are asked from the project's market, so the retrieved pages are that market's web.","allOf":[{"$ref":"#/components/schemas/AiSourcesAnswerLocaleResponse"}]},"answerText":{"type":"string","description":"The answer as the engine wrote it, verbatim — the evidence the companies below were read from. Engine prose, attributed to the engine that wrote it, never to CompetLab.","example":"For a Google Workspace domain, the safest warm-up tools are Competitor Inc and Rival, both of which…"},"companiesNamed":{"description":"The companies the answer named, in order of first mention in `answerText`, each verified present in the text. An EMPTY list is a measurement, never missing data: the engine answered and named no company — it explained a concept and recommended nobody. A question that produced no answer is not here at all; it is in `unansweredQueries`, and a question the engine showed no answer to is in `noAnswerShown`.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesAnswerCompanyResponse"}},"sources":{"description":"The pages the engine RETRIEVED while it answered, distinct by URL, each with whatever the engine reports about it. Retrieved, never cited: the engine does not say which of them it leaned on, and nothing here may be called a citation. ABSENT means the engine reported no retrieval for this answer — it reported none, and has not said how it answered — never that it read nothing we could see. An EMPTY array is the measured 'it reported retrieving nothing'.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesAnswerSourceResponse"}},"searchQueries":{"description":"The search strings the engine itself issued, as it reports them. Absent where the engine reports none. Strings, not a search count: one search can fan out into several.","example":["email warm-up tools Google Workspace safe"],"type":"array","items":{"type":"string"}},"webSearch":{"description":"Whether a live search ran for this answer. Absent means not recorded, never 'did not search'. `performed: false` is the engine's own record that no search ran, kept as it is.","allOf":[{"$ref":"#/components/schemas/AiSourcesWebSearchResponse"}]}},"required":["engine","promptIndex","promptId","promptText","intent","askedIn","answerText","companiesNamed"]},"AiSourcesUnansweredQueryResponse":{"type":"object","properties":{"engine":{"description":"Which engine this question was sent to.","example":"perplexity","allOf":[{"$ref":"#/components/schemas/AiSourcesEngine"}]},"promptIndex":{"type":"number","description":"Zero-based position of the question in this check's question list.","example":5},"promptId":{"type":"string","description":"Stable identifier of the question.","example":"7f3c1a2e-9d44-4c8b-9f0e-2b6a5c9d7e10"},"promptText":{"type":"string","description":"The question as it was sent.","example":"Which email warm-up tools have a free plan?"},"reason":{"type":"string","description":"`no_usable_answer` — we sent this question and did not end up with an answer we could use; names no actor and makes no claim about the question or the engine. `transcription_failed` — the engine answered and its pages were captured, but the step that reads the companies out of the text failed; the answer is kept as evidence and the slot enters no count until a recompute repairs it. Report either as 'we could not read this engine's answer to this question — not counted'. NEVER as 'not named' or '0 pages', and never attach 'failed' to the question, which the customer wrote.","enum":["no_usable_answer","transcription_failed"],"example":"no_usable_answer"}},"required":["engine","promptIndex","promptId","promptText","reason"]},"AiSourcesNoAnswerShownResponse":{"type":"object","properties":{"engine":{"description":"Which engine this question was sent to — we reached it and it showed no answer.","example":"google_ai_overviews","allOf":[{"$ref":"#/components/schemas/AiSourcesEngine"}]},"promptIndex":{"type":"number","description":"Zero-based position of the question in this check's question list.","example":3},"promptId":{"type":"string","description":"Stable identifier of the question.","example":"7f3c1a2e-9d44-4c8b-9f0e-2b6a5c9d7e10"},"promptText":{"type":"string","description":"The question as it was sent.","example":"Which email warm-up tools have a free plan?"},"reason":{"type":"string","description":"`no_ai_overview_shown` — Google's results page for this question carried no AI Overview. A measured fact about the question on Google that day: not a failure, not an answer, and nothing the customer's question did wrong. Report it as 'Google showed no AI Overview for this question — not counted'; NEVER as 'not named' or a question that failed. This slot is in no count on this response.","enum":["no_ai_overview_shown"],"example":"no_ai_overview_shown"}},"required":["engine","promptIndex","promptId","promptText","reason"]},"AiSourcesEngineStatusResponse":{"type":"object","properties":{"reported":{"type":"boolean","description":"Whether this engine stored a result for this check. `false` means nothing came back from it at all. `true` does NOT mean it answered usefully — read the three counts beside it.","example":true},"completedAt":{"type":"string","description":"When this engine finished (ISO-8601). Null when it never reported.","example":"2026-09-04T10:00:00.000Z","nullable":true},"questionsAsked":{"type":"number","description":"Questions this engine was asked — the check's own recorded question count.","example":8},"answersReceived":{"type":"number","description":"Of `questionsAsked`: questions it answered — the entries for this engine in `answers`, before any filter. The three counts here are separate facts; state the shortfall against `questionsAsked` as a count, never as a ratio.","example":7},"answersAbsent":{"type":"number","description":"Of `questionsAsked`: questions the engine was read for and showed no answer to — the entries for this engine in `noAnswerShown`. Not failures.","example":1},"answersUnmeasured":{"type":"number","description":"Of `questionsAsked`: questions we could not read the answer to — the entries for this engine in `unansweredQueries`. Our problem, and in no count.","example":0}},"required":["reported","questionsAsked","answersReceived","answersAbsent","answersUnmeasured"]},"AiSourcesEngineStatusMapResponse":{"type":"object","properties":{"perplexity":{"description":"Perplexity","allOf":[{"$ref":"#/components/schemas/AiSourcesEngineStatusResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews","allOf":[{"$ref":"#/components/schemas/AiSourcesEngineStatusResponse"}]}}},"AiSourcesDashboardResponse":{"type":"object","properties":{"lastUpdatedAt":{"type":"string","description":"When the data below was measured (ISO-8601): the completion time of the check the summary comes from, which is NOT necessarily the most recent cycle — see `latestCheckDataAvailable`.","example":"2026-09-04T10:00:00.000Z"},"summary":{"description":"The stored summary of the latest published check, exactly as the app shows it: per engine, who was named, what was read, and which of the pages more than one engine read name the customer's competitors and not them.","allOf":[{"$ref":"#/components/schemas/AiSourcesSummaryResponse"}]},"trackedDomains":{"description":"The domains the project tracks RIGHT NOW — the competitors plus the customer's own — normalised as the summary's domains are. Resolved against today's roster, not the one stored with the check, so a brand on `summary.brands` whose `origin` is `named_by_engines` and whose domain is here was added to the list after the check.","example":["mycompany.com","competitor.com"],"type":"array","items":{"type":"string"}},"latestCheckDataAvailable":{"description":"Present ONLY when the project's most recent check was abandoned — because no engine produced a usable answer, or because its page stage could not be closed; `reason` says which. When present, every other field in this response comes from an EARLIER check and `lastUpdatedAt` is older than the most recent check attempted. Absent when the most recent check published normally — and absent when an older check was abandoned but a later one has since published, because that failure is superseded.","allOf":[{"$ref":"#/components/schemas/AiSourcesLatestCheckUnavailableResponse"}]},"answers":{"description":"The engines' answers for this check — one entry per question that came back with an answer, with the pages the engine RETRIEVED to write it. Present ONLY when the request set `includeAnswers=true`; absent otherwise, never an empty array standing in for 'not requested'. Narrowed by `engine` and `promptIndex` when those are set — they narrow `unansweredQueries` and `noAnswerShown` the same way. No filter changes anything under `summary`: every number there is stored, computed over the whole check, and never recomputed for a filtered view. The pages on an answer are the pages the engine retrieved while answering, never a list of citations — the engines do not disclose which pages they leaned on. Never count pages across engines. ATTRIBUTION: the answer text and the companies read out of it are what that engine said, including about third parties CompetLab does not monitor. Attribute it to the named `engine`; do not republish it as fact.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesAnswerResponse"}},"unansweredQueries":{"description":"Questions this check sent that we could not read an answer to. Present ONLY when `includeAnswers=true`. Carried in their own array rather than mixed into `answers`, so that `answers.length` always means answers received. These slots are in no count on this response. Never quote this array's length as a fraction of anything: state it as a separate fact.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesUnansweredQueryResponse"}},"noAnswerShown":{"description":"Questions an engine was read for and showed no answer to — today, questions Google's results page carried no AI Overview for. Not a failure and not an answer: in no count on this response. Separate from `unansweredQueries`, which are questions we could not read. Present ONLY when `includeAnswers=true`. Narrowed by `engine` and `promptIndex`.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesNoAnswerShownResponse"}},"engineStatus":{"description":"Per-engine reporting status for this check: whether each engine this check asked stored a result, when, and how its questions split between answered, no answer shown and unmeasured. One entry per engine the check ASKED — an engine is absent when the check did not ask it; absent means not measured, never 'did not report'. Present ONLY when `includeAnswers=true`. NOT narrowed by the filters: it always describes the whole check.","allOf":[{"$ref":"#/components/schemas/AiSourcesEngineStatusMapResponse"}]},"answersTruncated":{"type":"boolean","description":"True when the answers payload hit the response size cap and answers were dropped from the end of `answers`. The cut lands on a WHOLE QUESTION ROUND, never inside one: `answers` is ordered question by question across the engines, so truncation drops the highest `promptIndex` values and no engine is ever removed by truncation from a question that is still present — group the array by `promptIndex` and no round is short. Neither `unansweredQueries` nor `noAnswerShown` is ever truncated, so a question absent from `answers` is not a question no engine answered. An engine missing from a question that IS present is in one of those two arrays, not cut; an engine missing from EVERY question stored nothing for this check, which `engineStatus` reports as its three counts summing below `questionsAsked`. An answer's page list is never partially truncated. `summary` and `engineStatus` are never affected. Narrow with `engine` or `promptIndex` to get a complete view. Present ONLY when `includeAnswers=true`.","example":false}},"required":["lastUpdatedAt","summary","trackedDomains"]},"AiSourcesHistoryEngineResponse":{"type":"object","properties":{"answersReceived":{"type":"number","description":"Questions this engine answered on this check — the denominator of `answersNamingCustomer`.","example":7},"answersNamingCustomer":{"type":"number","description":"Of `answersReceived`: answers that named the customer.","example":3},"pagesRead":{"type":"number","description":"Pages this engine retrieved on this check whose body we read — the universe of `independentPagesNamingCustomer`.","example":118},"independentPagesNamingCustomer":{"description":"Independent pages this engine retrieved on this check whose text names the customer, over `pagesRead`. A range; this engine's number alone.","allOf":[{"$ref":"#/components/schemas/AiSourcesCountRangeResponse"}]}},"required":["answersReceived","answersNamingCustomer","pagesRead","independentPagesNamingCustomer"]},"AiSourcesHistoryPerEngineResponse":{"type":"object","properties":{"perplexity":{"description":"Perplexity.","allOf":[{"$ref":"#/components/schemas/AiSourcesHistoryEngineResponse"}]},"google_ai_overviews":{"description":"Google AI Overviews.","allOf":[{"$ref":"#/components/schemas/AiSourcesHistoryEngineResponse"}]}}},"AiSourcesHistoryItemResponse":{"type":"object","properties":{"checkId":{"type":"string","description":"Check ID — pass it to the check-detail route.","example":"507f1f77bcf86cd799439012"},"completedAt":{"type":"string","description":"When this check published (ISO-8601).","example":"2026-09-04T10:00:00.000Z"},"perEngine":{"description":"Per engine, the four measured figures for this check. A key is ABSENT for an engine the check did not ask, and for an engine that produced nothing usable on it — absent means not measured, never a zero. Nothing here is summed across engines.","allOf":[{"$ref":"#/components/schemas/AiSourcesHistoryPerEngineResponse"}]},"funnel":{"description":"The narrowing from core hosts to hosts the customer is genuinely missing from, on this check.","allOf":[{"$ref":"#/components/schemas/AiSourcesFunnelResponse"}]}},"required":["checkId","completedAt","perEngine","funnel"]},"AiSourcesCheckDetailResponse":{"type":"object","properties":{"checkId":{"type":"string","description":"Check ID","example":"507f1f77bcf86cd799439012"},"completedAt":{"type":"string","description":"When this check published (ISO-8601).","example":"2026-09-04T10:00:00.000Z"},"summary":{"description":"The summary stored on this check, exactly as the app shows it — the same shape as the dashboard's `summary`, as of this check.","allOf":[{"$ref":"#/components/schemas/AiSourcesSummaryResponse"}]},"answers":{"description":"The engines' answers for this check — one entry per question that came back with an answer, with the pages the engine RETRIEVED to write it. Present ONLY when the request set `includeAnswers=true`; absent otherwise, never an empty array standing in for 'not requested'. Narrowed by `engine` and `promptIndex` when those are set — they narrow `unansweredQueries` and `noAnswerShown` the same way. No filter changes anything under `summary`: every number there is stored, computed over the whole check, and never recomputed for a filtered view. The pages on an answer are the pages the engine retrieved while answering, never a list of citations — the engines do not disclose which pages they leaned on. Never count pages across engines. ATTRIBUTION: the answer text and the companies read out of it are what that engine said, including about third parties CompetLab does not monitor. Attribute it to the named `engine`; do not republish it as fact.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesAnswerResponse"}},"unansweredQueries":{"description":"Questions this check sent that we could not read an answer to. Present ONLY when `includeAnswers=true`. Carried in their own array rather than mixed into `answers`, so that `answers.length` always means answers received. These slots are in no count on this response. Never quote this array's length as a fraction of anything: state it as a separate fact.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesUnansweredQueryResponse"}},"noAnswerShown":{"description":"Questions an engine was read for and showed no answer to — today, questions Google's results page carried no AI Overview for. Not a failure and not an answer: in no count on this response. Separate from `unansweredQueries`, which are questions we could not read. Present ONLY when `includeAnswers=true`. Narrowed by `engine` and `promptIndex`.","type":"array","items":{"$ref":"#/components/schemas/AiSourcesNoAnswerShownResponse"}},"engineStatus":{"description":"Per-engine reporting status for this check: whether each engine this check asked stored a result, when, and how its questions split between answered, no answer shown and unmeasured. One entry per engine the check ASKED — an engine is absent when the check did not ask it; absent means not measured, never 'did not report'. Present ONLY when `includeAnswers=true`. NOT narrowed by the filters: it always describes the whole check.","allOf":[{"$ref":"#/components/schemas/AiSourcesEngineStatusMapResponse"}]},"answersTruncated":{"type":"boolean","description":"True when the answers payload hit the response size cap and answers were dropped from the end of `answers`. The cut lands on a WHOLE QUESTION ROUND, never inside one: `answers` is ordered question by question across the engines, so truncation drops the highest `promptIndex` values and no engine is ever removed by truncation from a question that is still present — group the array by `promptIndex` and no round is short. Neither `unansweredQueries` nor `noAnswerShown` is ever truncated, so a question absent from `answers` is not a question no engine answered. An engine missing from a question that IS present is in one of those two arrays, not cut; an engine missing from EVERY question stored nothing for this check, which `engineStatus` reports as its three counts summing below `questionsAsked`. An answer's page list is never partially truncated. `summary` and `engineStatus` are never affected. Narrow with `engine` or `promptIndex` to get a complete view. Present ONLY when `includeAnswers=true`.","example":false}},"required":["checkId","completedAt","summary"]},"AlertListItemResponse":{"type":"object","properties":{"id":{"type":"string","description":"Alert ID","example":"507f1f77bcf86cd799439011"},"dimension":{"type":"string","description":"Monitoring dimension that generated this alert","enum":["tech-trust","content","positioning","pricing","ai-visibility","ai-sources"],"example":"pricing"},"competitorDomain":{"type":"string","description":"Domain of the company the alert is about. On an ai-visibility untracked_brand_recommended alert this is the company that is NOT on the competitor list, while competitorId is the customer's own row.","example":"competitor.com"},"competitorId":{"type":"string","description":"ID of the competitor row the alert hangs on. The customer's own row for their own AI Visibility standing, the prompt-market reading, and untracked_brand_recommended.","example":"507f1f77bcf86cd799439012"},"severity":{"type":"string","description":"Alert severity level","enum":["critical","high","medium","info"],"example":"high"},"title":{"type":"string","description":"Alert title describing the detected change","example":"Pricing plan restructure detected"},"actionHint":{"type":"string","description":"Suggested action for the user","example":"Review your pricing strategy to stay competitive"},"gapWarnings":{"description":"Specific competitive gap warnings","example":["Missing free tier","No annual billing discount"],"type":"array","items":{"type":"string"}},"runId":{"type":"string","description":"ID of the monitoring run that generated this alert. Use with the dimension's history endpoint (e.g., GET /v1/projects/{projectId}/pricing/history/{runId}) to retrieve the full data from that monitoring cycle.","example":"507f1f77bcf86cd799439013"},"alertType":{"type":"string","description":"Alert sub-type classifier. Values vary by dimension — e.g., price_change, plan_restructure, feature_toggle (pricing), messaging_shift, positioning_change (positioning), content_growth, content_removal (content), tech_stack_evolution, crawl_strategy (tech-trust), own_standing_changed, rival_standing_changed, untracked_brand_recommended, prompt_market_changed (ai-visibility). AI Visibility alerts report who the AI models recommend, never score movement.","example":"price_change"},"context":{"type":"object","description":"Dimension-specific context, keyed by the canonical dimension slug — identical to the `dimension` field (e.g. an ai-visibility alert has an `ai-visibility` key), so you can index context[dimension]. Always present (an empty object when the alert has no extra context). Structure varies by dimension: most carry field-level changes with previousValue/currentValue pairs; AI Visibility carries either a standingChange (one brand's zone on the market map, the last reading that held and the current one, each with its presence, its range, and the answers it was drawn from; before is two or more published checks back — beforeChecksAgo says how many — and null when the brand was on no map then) or a promptMarketChange (the last prompt-market state that held, fromChecksAgo checks back, and the current one, with the sentence the app shows for the new state)."},"createdAt":{"type":"string","description":"When the alert was created (ISO-8601)","example":"2026-03-15T10:00:00.000Z"}},"required":["id","dimension","competitorDomain","competitorId","severity","title","actionHint","gapWarnings","runId","context","createdAt"]},"ScheduleItemResponse":{"type":"object","properties":{"id":{"type":"string","description":"Schedule ID","example":"507f1f77bcf86cd799439011"},"dimension":{"type":"string","description":"Monitoring dimension","example":"tech-trust","enum":["tech-trust","content","positioning","pricing","ai-visibility","ai-sources"]},"enabled":{"type":"boolean","description":"Whether scheduled monitoring is enabled","example":true},"intervalDays":{"type":"number","description":"Monitoring interval in days","example":14},"nextRunAt":{"type":"string","description":"Next scheduled run time (ISO-8601)","example":"2026-03-29T10:00:00.000Z"},"lastRunAt":{"type":"string","description":"Last completed run time (ISO-8601)","example":"2026-03-15T10:00:00.000Z","nullable":true}},"required":["id","dimension","enabled","intervalDays","nextRunAt","lastRunAt"]},"BriefingRunStatus":{"type":"string","enum":["running","done","failed"],"description":"Lifecycle status of this run.\n\n- `running` — being generated now; see `progress`. `item` is null, but an earlier edition is usually still readable via `GET /strategic-briefing/history`. Never report that no briefing exists without checking there first.\n- `done` — finished; `item`, `coverage` and `contains` are populated.\n- `failed` — this attempt ended without producing an edition. `item` is null; earlier editions remain readable via `GET /strategic-briefing/history`. **A failed run does not resume the ~30-day cycle** — surface it rather than waiting it out.\n- `null` — this project has never had a briefing run at all. This is the only value that means the project genuinely has nothing."},"BriefingProgressStep":{"type":"string","enum":["researching","composing","verifying"],"description":"Coarse activity label for the run in flight, in the order they occur: `researching` gathers evidence per dimension, `composing` writes the edition, `verifying` checks the draft's load-bearing claims against their sources and repairs what the check found. Treat it as a label, not a progress percentage."},"BriefingProgressResponse":{"type":"object","properties":{"step":{"description":"Coarse activity label for the run in flight, in the order they occur: `researching` gathers evidence per dimension, `composing` writes the edition, `verifying` checks the draft's load-bearing claims against their sources and repairs what the check found. Treat it as a label, not a progress percentage.","example":"researching","allOf":[{"$ref":"#/components/schemas/BriefingProgressStep"}]},"startedAt":{"type":"string","description":"When this run started, ISO-8601 UTC.","example":"2026-07-27T06:00:11.002Z","format":"date-time"},"updatedAt":{"type":"string","description":"When this progress snapshot was last refreshed, ISO-8601 UTC. Updates roughly once a minute while a run is in flight. Short gaps are ordinary and a single missed update is not a signal; we do not treat a run as stalled until it has been quiet far longer than that, so use `status` rather than this timestamp to decide a run has stopped.","example":"2026-07-27T06:41:03.771Z","format":"date-time"}},"required":["step","startedAt","updatedAt"]},"BriefingMetaResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Id of this briefing run — also the id of the edition it produced, if it finished. Pass it to `/strategic-briefing/history/{runId}` to fetch this run again later. Null only when the project has never had a briefing run.","example":"6a3950c06bda450a1372048e","nullable":true},"status":{"description":"Lifecycle status of this run.\n\n- `running` — being generated now; see `progress`. `item` is null, but an earlier edition is usually still readable via `GET /strategic-briefing/history`. Never report that no briefing exists without checking there first.\n- `done` — finished; `item`, `coverage` and `contains` are populated.\n- `failed` — this attempt ended without producing an edition. `item` is null; earlier editions remain readable via `GET /strategic-briefing/history`. **A failed run does not resume the ~30-day cycle** — surface it rather than waiting it out.\n- `null` — this project has never had a briefing run at all. This is the only value that means the project genuinely has nothing.","example":"done","nullable":true,"allOf":[{"$ref":"#/components/schemas/BriefingRunStatus"}]},"editionNumber":{"type":"number","description":"1-indexed ordinal of this edition among the project's published editions — the first briefing is 1, the next is 2. Counts finished editions only, so it never skips. Null unless `status` is `done`. A display value: address editions by `runId`.","example":4,"minimum":1,"nullable":true},"startedAt":{"type":"string","description":"When this run started, ISO-8601 UTC. Where a run was re-attempted this is the latest attempt's start, so it is the right anchor for elapsed duration.","example":"2026-06-22T14:20:02.110Z","format":"date-time","nullable":true},"briefingDate":{"type":"string","description":"When this run reached a terminal state, ISO-8601 UTC — for a `done` run, the edition's publication date. Null while `running`. A project's next briefing is scheduled roughly 30 days after its last **successful** one.","example":"2026-06-22T15:51:24.443Z","format":"date-time","nullable":true},"progress":{"description":"In-flight telemetry. Non-null only while `status` is `running` AND at least one progress update has landed (roughly a minute in). Always null on a `done` or `failed` run. Null is normal, not an error. A run typically finishes in about two hours, so budget rather than polling tightly, and never infer failure from elapsed time.","nullable":true,"allOf":[{"$ref":"#/components/schemas/BriefingProgressResponse"}]}}},"BriefingSectionName":{"type":"string","enum":["hub","actions","competitors","deep-ai-visibility","deep-ai-sources","deep-positioning","deep-pricing","deep-content","deep-tech-trust","deep-agent-readiness","deep-ai-ecosystem","deep-customer-voice","deep-funding-capital","deep-hiring-gtm","deep-landscape","deep-product-launches","deep-reliability-status"],"description":"Manifest of what this edition actually holds — every section it contains, whether or not you requested it. Values are exactly the tokens the `sections` parameter accepts, so you can pass one straight back. Use it to decide what to fetch next rather than requesting slots blind: a section absent from this list does not exist for this edition, and requesting it is not an error — the key is simply missing from `item`. Null whenever `meta.status` is not `done`. That means *this run* holds no content — **not** that the project has no editions; check `GET /strategic-briefing/history`."},"BriefingEnvelopeResponse":{"type":"object","properties":{"meta":{"description":"State of the briefing run this response describes. Branch on `meta.status`. Always present.","allOf":[{"$ref":"#/components/schemas/BriefingMetaResponse"}]},"item":{"type":"object","description":"The requested briefing sections, keyed by section name. Controlled by the `sections` query parameter, which defaults to `[\"hub\"]`. Free-form — each section is a generated block array. **Null unless `meta.status` is `done`**: a run that is still generating, or that failed, has no content of its own. That says nothing about whether the project has a briefing — an earlier edition is usually still readable via `GET /strategic-briefing/history`.","additionalProperties":true,"nullable":true,"example":{"hub":{"kind":"hub","blocks":[{"type":"headline","verdict":"Pricing pressure is rising."}]}}},"coverage":{"type":"object","description":"Methodology and data-quality caveats for this edition — which figures are directional, what counts as noise, which sources were unavailable. Returned regardless of which sections were requested; read it before quoting any number. Null whenever `meta.status` is not `done`, and when a finished edition carries no coverage block. Null here means *this run* has no content — **not** that the project has no briefing; an earlier edition may still be readable via `GET /strategic-briefing/history`.","additionalProperties":true,"nullable":true,"example":{"type":"coverage","items":["review counts are directional"]}},"contains":{"type":"array","description":"Manifest of what this edition actually holds — every section it contains, whether or not you requested it. Values are exactly the tokens the `sections` parameter accepts, so you can pass one straight back. Use it to decide what to fetch next rather than requesting slots blind: a section absent from this list does not exist for this edition, and requesting it is not an error — the key is simply missing from `item`. Null whenever `meta.status` is not `done`. That means *this run* holds no content — **not** that the project has no editions; check `GET /strategic-briefing/history`.","nullable":true,"example":["hub","actions","competitors","deep-ai-visibility","deep-pricing"],"items":{"$ref":"#/components/schemas/BriefingSectionName"}}},"required":["meta"]},"BriefingHistoryItemResponse":{"type":"object","properties":{"runId":{"type":"string","description":"Id of this run. Pass it to `/strategic-briefing/history/{runId}` to read the edition in full.","example":"6a3950c06bda450a1372048e"},"editionNumber":{"type":"number","description":"1-indexed ordinal among the project's published editions, oldest first. Null for a run that never produced one (`running` or `failed`).","example":4,"minimum":1,"nullable":true},"briefingDate":{"type":"string","description":"When the run that produced this edition finished, ISO-8601 UTC. Null for a run that has not finished. To DISPLAY an edition's date prefer `documentDate` — that is the date the edition itself carries, and it is what the edition's own header shows.","example":"2026-06-22T15:51:24.443Z","format":"date-time","nullable":true},"documentDate":{"type":"string","description":"The date this edition carries in its own header, as the edition states it. Prefer it over `briefingDate` when showing a date to a person: a run that starts before midnight and finishes after it would otherwise be listed under one date and open showing another. Null for a run that produced no edition, and for an edition that records no date of its own — fall back to `briefingDate` there.","example":"2026-06-22","nullable":true},"status":{"type":"string","description":"Lifecycle status of this run. `failed` and `running` rows are returned too — a gap between two editions is explained rather than left unexplained.","enum":["running","done","failed"],"example":"done"},"headline":{"type":"string","description":"This edition's one-line headline verdict — the single sentence summarising what it concluded. Present so you can pick which edition to open without fetching each one. Null for a run that produced no edition, and for any edition that carries no headline.","example":"Pricing pressure is rising in your segment.","nullable":true}},"required":["runId","status"]},"TechStackEvidenceResponse":{"type":"object","properties":{"kind":{"type":"string","description":"Match source: 'header' = HTTP response header; 'html' = homepage body","enum":["header","html"],"example":"html"},"source":{"type":"string","description":"Human-readable source: header key name (e.g. 'x-vercel-id') or the regex pattern that fired","example":"/_next/static/"},"matched":{"type":"string","description":"The specific text that matched, newline-stripped and truncated for safety","example":"<script src=\"/_next/static/chunks/main-app-abc123.js\""}},"required":["kind","source","matched"]},"TechStackDetectedTechnologyResponse":{"type":"object","properties":{"name":{"type":"string","description":"Canonical technology name (e.g. \"Next.js\", \"Cloudflare\", \"HubSpot\")","example":"Next.js"},"evidenceCount":{"type":"number","description":"Count of independent matchers that fired for this technology","example":3},"evidence":{"description":"One entry per matcher that fired — lets consumers show 'why did this match?'","type":"array","items":{"$ref":"#/components/schemas/TechStackEvidenceResponse"}}},"required":["name","evidenceCount","evidence"]},"PartialDetectionResponse":{"type":"object","properties":{"headersAvailable":{"type":"boolean","description":"Always false for this discriminator.","example":false},"reason":{"type":"string","description":"Why detection was partial: this site runs behavioral protection that hid its response headers from us. The page body was still read in full, so 88% of our 117 detection rules still ran and everything they found is real. The 13 rules that read headers and nothing else could not run at all, so these technologies were not looked for on this scan: Vercel, Netlify, AWS, Heroku, GitHub Pages, DigitalOcean, Render, Cloudflare, Fastly, Akamai, CloudFront, Bunny CDN, KeyCDN. None of them being listed is therefore not evidence the site doesn't use them, and no count in this response may be compared against a scan of a site whose headers we did read.","enum":["site_uses_behavioral_protection"],"example":"site_uses_behavioral_protection"}},"required":["headersAvailable","reason"]},"TechStackToolResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Normalized domain, as actually requested (after normalization)","example":"competlab.com"},"finalUrl":{"type":"string","description":"Final URL after redirects","example":"https://competlab.com/"},"fetchedAt":{"type":"string","description":"ISO-8601 timestamp of when detection completed","example":"2026-05-16T14:30:00.000Z"},"totalTechnologies":{"type":"number","description":"Total count of detected technologies across all three stacks. When `partialDetection` is present this is a FLOOR, not a total — 13 technologies could not be looked for on this scan, so the real figure is this number plus however many of them the site actually uses. While that marker is set, never compare this count against another domain's, and never phrase it as \"N technologies against your M\".","example":12},"techStack":{"description":"Tech stack — hosting, CDN, frameworks, CMS, auth, payments, cookie consent","type":"array","items":{"$ref":"#/components/schemas/TechStackDetectedTechnologyResponse"}},"growthStack":{"description":"Growth stack — analytics, tag managers, session recording, marketing, CRM, advertising, A/B","type":"array","items":{"$ref":"#/components/schemas/TechStackDetectedTechnologyResponse"}},"engagementStack":{"description":"Engagement stack — customer support, forms, video, monitoring","type":"array","items":{"$ref":"#/components/schemas/TechStackDetectedTechnologyResponse"}},"partialDetection":{"description":"Present only when response headers couldn't be recovered (~1.1% of sites running behavioral protection that blocks header inspection). The three stack arrays are still populated from the page body and every detection in them is real — this marks a PARTIAL scan, not a failed one. What is missing is the 13 technologies detectable only from response headers; see `reason` for the list. While this is present, a technology's absence from the arrays is not evidence the site doesn't use it, and `totalTechnologies` is a floor rather than a total.","allOf":[{"$ref":"#/components/schemas/PartialDetectionResponse"}]}},"required":["domain","finalUrl","fetchedAt","totalTechnologies","techStack","growthStack","engagementStack"]},"TechStackScanErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable error code","enum":["homepage_fetch_failed","scan_failed"],"example":"homepage_fetch_failed"},"message":{"type":"string","description":"Human-readable error message","example":"Homepage fetch failed"}},"required":["code","message"]},"TechStackScanResponse":{"type":"object","properties":{"id":{"type":"string","description":"Scan ID (24-char hex identifier). Use to poll GET /scans/:scanId.","example":"6638e9a1b2c3d4e5f6a7b8c9"},"status":{"type":"string","description":"Current lifecycle state. Poll until `completed` or `failed`.","enum":["queued","running","completed","failed"],"example":"queued"},"createdAt":{"type":"string","description":"ISO-8601 timestamp when the scan was created.","example":"2026-05-17T14:30:00.000Z"},"expiresAt":{"type":"string","description":"ISO-8601 timestamp when the doc auto-deletes (createdAt + 24h). After this, GET returns 404.","example":"2026-05-18T14:30:00.000Z"},"startedAt":{"type":"string","description":"ISO-8601 timestamp when the background runner began scanning. Set once status leaves `queued`.","example":"2026-05-17T14:30:00.412Z"},"completedAt":{"type":"string","description":"ISO-8601 timestamp when the scan reached a terminal state (completed or failed).","example":"2026-05-17T14:30:45.218Z"},"result":{"description":"Full canonical tech-stack result. Present only when `status === \"completed\"`.","allOf":[{"$ref":"#/components/schemas/TechStackToolResponse"}]},"error":{"description":"Error details. Present only when `status === \"failed\"`.","allOf":[{"$ref":"#/components/schemas/TechStackScanErrorResponse"}]}},"required":["id","status","createdAt","expiresAt"]},"PTTechStackRequestDto":{"type":"object","properties":{"domain":{"type":"string","description":"Target domain to fingerprint. Accepts a bare hostname or a full URL — normalized (lowercased, scheme and path stripped) before scanning.","example":"stripe.com"}},"required":["domain"]},"ApiRateLimitErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable rate-limit error code.","enum":["rate_limit_exceeded"],"example":"rate_limit_exceeded"},"message":{"type":"string","description":"Human-readable error message","example":"Rate limit exceeded for this API key — retry after 60 seconds."},"status":{"type":"number","description":"HTTP status code","example":429}},"required":["code","message","status"]},"ApiRateLimitErrorEnvelope":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/ApiRateLimitErrorResponse"}},"required":["error"]},"ApiBadGatewayErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable code for an upstream failure while servicing the request.","enum":["bad_gateway","bot_protection_blocked","fetch_failed"],"example":"bad_gateway"},"message":{"type":"string","description":"Human-readable error message","example":"Upstream fetch failed"},"status":{"type":"number","description":"HTTP status code","example":502}},"required":["code","message","status"]},"ApiBadGatewayErrorEnvelope":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/ApiBadGatewayErrorResponse"}},"required":["error"]},"HeaderInspectionUnavailableResponse":{"type":"object","properties":{"available":{"type":"boolean","description":"Always false for this marker — header inspection did not happen.","example":false},"reason":{"type":"string","description":"Why headers couldn't be inspected: this site runs behavioral protection that hides response headers from us. The page body was still read in full, and since no trust-signal rule reads headers, every one of the 34 rules ran and the scan is complete. Nothing in the result is suppressed, reduced or provisional because of this.","enum":["site_uses_behavioral_protection"],"example":"site_uses_behavioral_protection"}},"required":["available","reason"]},"TrustSignalsVerdictResponse":{"type":"object","properties":{"tier":{"type":"string","description":"Tier verdict — describes homepage trust-signal coverage, not company market tier","enum":["comprehensive","substantial","moderate","minimal"],"example":"substantial"},"score":{"type":"number","description":"Total trust score (0-100, capped)","example":38},"benchmarkForTier":{"type":"number","description":"Tier mean score from CompetLab SaaS Trust Benchmarks","example":34},"benchmarkComparison":{"type":"string","description":"How this domain compares to peers at the same tier","enum":["above-average","at-average","below-average"],"example":"above-average"},"summary":{"type":"string","description":"One-line human-readable verdict","example":"Substantial trust coverage — strong social proof and some compliance signals, above-average for tier"}},"required":["tier","score","benchmarkForTier","benchmarkComparison","summary"]},"TrustSignalsCategoryScoreResponse":{"type":"object","properties":{"score":{"type":"number","description":"Points earned in this category (post-cap, post-weight)","example":18},"max":{"type":"number","description":"Maximum possible points in this category","example":25},"signalsFound":{"type":"number","description":"Count of distinct signals detected in this category","example":3}},"required":["score","max","signalsFound"]},"TrustSignalsCategoryScoresResponse":{"type":"object","properties":{"enterpriseReadiness":{"description":"Enterprise readiness — compliance, trust center, privacy posture","allOf":[{"$ref":"#/components/schemas/TrustSignalsCategoryScoreResponse"}]},"thirdPartyValidation":{"description":"Third-party validation — G2, Capterra, Trustpilot, Gartner, etc.","allOf":[{"$ref":"#/components/schemas/TrustSignalsCategoryScoreResponse"}]},"socialProof":{"description":"Social proof — customer logos, case studies, testimonials, count claims","allOf":[{"$ref":"#/components/schemas/TrustSignalsCategoryScoreResponse"}]},"brandAuthority":{"description":"Brand authority — press, awards, funding, partner badges","allOf":[{"$ref":"#/components/schemas/TrustSignalsCategoryScoreResponse"}]},"riskReversal":{"description":"Risk reversal — free trial, freemium, no-credit-card","allOf":[{"$ref":"#/components/schemas/TrustSignalsCategoryScoreResponse"}]}},"required":["enterpriseReadiness","thirdPartyValidation","socialProof","brandAuthority","riskReversal"]},"TrustSignalsEvidenceResponse":{"type":"object","properties":{"kind":{"type":"string","description":"Match source: 'header' = HTTP response header; 'html' = homepage body","enum":["header","html"],"example":"html"},"source":{"type":"string","description":"Human-readable source: header key name or the regex pattern that fired","example":"trustpilot.com"},"matched":{"type":"string","description":"The specific text that matched, newline-stripped and truncated for safety","example":"<a href=\"https://www.trustpilot.com/review/example.com\">"}},"required":["kind","source","matched"]},"TrustSignalsDetectedSignalResponse":{"type":"object","properties":{"signal":{"type":"string","description":"Canonical trust signal name (e.g. \"SOC 2\", \"Customer logos\")","example":"SOC 2"},"category":{"type":"string","description":"Buyer-concern category this signal belongs to","enum":["enterpriseReadiness","thirdPartyValidation","socialProof","brandAuthority","riskReversal"],"example":"enterpriseReadiness"},"weight":{"type":"number","description":"Points this signal contributed to the total score (post-cap)","example":8},"confidence":{"type":"string","description":"Confidence band — reflects match specificity, not business weight","enum":["high","medium","low"],"example":"high"},"evidenceCount":{"type":"number","description":"Count of independent matchers that fired for this signal","example":2},"evidence":{"description":"One entry per matcher that fired — lets consumers show 'why did this match?'","type":"array","items":{"$ref":"#/components/schemas/TrustSignalsEvidenceResponse"}},"extractedValue":{"type":"object","description":"Optional extracted value — e.g., customer count number, case study count","example":50000}},"required":["signal","category","weight","confidence","evidenceCount","evidence"]},"TrustSignalsSuspiciousPatternResponse":{"type":"object","properties":{"type":{"type":"string","description":"Type of asymmetry detected on the page","enum":["unverified-compliance-claim","count-vs-logo-mismatch","unlinked-press-claim","stale-review-widget"],"example":"unverified-compliance-claim"},"severity":{"type":"string","description":"Severity of the discrepancy","enum":["low","medium","high"],"example":"high"},"description":{"type":"string","description":"Human-readable description of the pattern","example":"SOC 2 text found on homepage but no link to attestation or trust center"},"ciInsight":{"type":"string","description":"Short actionable competitive intelligence insight — under 25 words","example":"Likely SOC 2 claim without proof — competitive opportunity if you have attestation linked"},"evidence":{"description":"Evidence supporting the pattern detection","type":"array","items":{"$ref":"#/components/schemas/TrustSignalsEvidenceResponse"}}},"required":["type","severity","description","ciInsight","evidence"]},"TrustSignalsGapResponse":{"type":"object","properties":{"signal":{"type":"string","description":"Canonical trust signal name that is missing","example":"Customer logos"},"category":{"type":"string","description":"Buyer-concern category this gap belongs to","enum":["enterpriseReadiness","thirdPartyValidation","socialProof","brandAuthority","riskReversal"],"example":"socialProof"},"benchmarkPrevalence":{"type":"string","description":"Human-readable prevalence among peers at the same tier","example":"87% of substantial-tier peers have this"},"ciInsight":{"type":"string","description":"Short actionable insight on closing the gap","example":"Add customer logos to homepage — table stakes for substantial-tier SaaS positioning"}},"required":["signal","category","benchmarkPrevalence","ciInsight"]},"TrustSignalsMetaResponse":{"type":"object","properties":{"signalsEvaluated":{"type":"number","description":"How many canonical trust signals this scan evaluated — the full catalogue on every live scan, `headerInspection` present or not, because every rule reads the page HTML and none reads headers. `null` only on a scan stored before the header short-circuit was removed, where nothing was evaluated at all. Never read a null here as zero coverage; it means the scan didn't happen, not that the site scored nothing.","example":34,"nullable":true},"benchmarkSource":{"type":"string","description":"Benchmark dataset identifier — quarter and sample size","example":"CompetLab SaaS Trust Benchmarks 2026-Q2 (n=247)"},"scanDurationMs":{"type":"number","description":"Total scan duration in milliseconds","example":2840}},"required":["signalsEvaluated","benchmarkSource","scanDurationMs"]},"TrustSignalsToolResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Normalized domain, as actually requested (after normalization)","example":"competlab.com"},"finalUrl":{"type":"string","description":"Final URL after redirects","example":"https://competlab.com/"},"fetchedAt":{"type":"string","description":"ISO-8601 timestamp of when detection completed","example":"2026-05-16T14:30:00.000Z"},"headerInspection":{"description":"Header-inspection marker. Present only when the upstream fetch couldn't recover response headers (~1.1% of sites running behavioral protection that blocks header inspection), and absent on every other scan. It does NOT suppress or qualify anything below it: the page body arrived in full, all 34 trust-signal rules read the body and nothing else, and the scan beside this marker is complete — its score, tier, category breakdown and `meta.signalsEvaluated` are exact and comparable against any other domain's scan. Do not report those results as partial, provisional or a minimum. What the marker does record is that the header evidence channel was closed on this fetch, so no `evidence` entry here can carry `kind: \"header\"` — it is present so that when a header-reading rule is added, consumers already know which scans could not have run it.","allOf":[{"$ref":"#/components/schemas/HeaderInspectionUnavailableResponse"}]},"verdict":{"description":"Overall verdict — tier, score, benchmark comparison, human-readable summary. Null only on a scan STORED before the header short-circuit was removed (results persist 24h), where it means the page was never inspected — never tier 'minimal' and never score 0. A live scan always populates it, including on the sites that carry `headerInspection`.","nullable":true,"allOf":[{"$ref":"#/components/schemas/TrustSignalsVerdictResponse"}]},"categoryScores":{"description":"Per-category breakdown — score, max, and signals found in each of the 5 categories. Null only on a pre-fix stored scan; see `verdict`.","nullable":true,"allOf":[{"$ref":"#/components/schemas/TrustSignalsCategoryScoresResponse"}]},"signalsDetected":{"description":"All trust signals detected on the homepage, with evidence. Null only on a pre-fix stored scan; see `verdict`. An empty array is a different and real finding: we read the page and found none.","nullable":true,"type":"array","items":{"$ref":"#/components/schemas/TrustSignalsDetectedSignalResponse"}},"suspiciousPatterns":{"description":"Asymmetry detection — claims without proof, count/logo mismatches, unlinked press, etc. Null only on a pre-fix stored scan; see `verdict`. An empty array means we looked and found none.","nullable":true,"type":"array","items":{"$ref":"#/components/schemas/TrustSignalsSuspiciousPatternResponse"}},"gapsVsBenchmark":{"description":"Missing signals relative to tier benchmark — drives competitive insights. Null only on a pre-fix stored scan; see `verdict`. An empty array means none are missing.","nullable":true,"type":"array","items":{"$ref":"#/components/schemas/TrustSignalsGapResponse"}},"meta":{"description":"Scan metadata — signals evaluated, benchmark source, scan duration","allOf":[{"$ref":"#/components/schemas/TrustSignalsMetaResponse"}]}},"required":["domain","finalUrl","fetchedAt","verdict","categoryScores","signalsDetected","suspiciousPatterns","gapsVsBenchmark","meta"]},"TrustSignalsScanErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable error code","enum":["homepage_fetch_failed","scan_failed"],"example":"homepage_fetch_failed"},"message":{"type":"string","description":"Human-readable error message","example":"Homepage fetch failed"}},"required":["code","message"]},"TrustSignalsScanResponse":{"type":"object","properties":{"id":{"type":"string","description":"Scan ID (24-char hex identifier). Use to poll GET /scans/:scanId.","example":"6638e9a1b2c3d4e5f6a7b8c9"},"status":{"type":"string","description":"Current lifecycle state. Poll until `completed` or `failed`.","enum":["queued","running","completed","failed"],"example":"queued"},"createdAt":{"type":"string","description":"ISO-8601 timestamp when the scan was created.","example":"2026-05-17T14:30:00.000Z"},"expiresAt":{"type":"string","description":"ISO-8601 timestamp when the doc auto-deletes (createdAt + 24h). After this, GET returns 404.","example":"2026-05-18T14:30:00.000Z"},"startedAt":{"type":"string","description":"ISO-8601 timestamp when the background runner began scanning. Set once status leaves `queued`.","example":"2026-05-17T14:30:00.412Z"},"completedAt":{"type":"string","description":"ISO-8601 timestamp when the scan reached a terminal state (completed or failed).","example":"2026-05-17T14:30:45.218Z"},"result":{"description":"Full canonical trust-signals result. Present only when `status === \"completed\"`.","allOf":[{"$ref":"#/components/schemas/TrustSignalsToolResponse"}]},"error":{"description":"Error details. Present only when `status === \"failed\"`.","allOf":[{"$ref":"#/components/schemas/TrustSignalsScanErrorResponse"}]}},"required":["id","status","createdAt","expiresAt"]},"PTTrustSignalsRequestDto":{"type":"object","properties":{"domain":{"type":"string","description":"Target domain to scan for trust signals. Accepts a bare hostname or a full URL — normalized (lowercased, scheme and path stripped) before scanning.","example":"stripe.com"}},"required":["domain"]},"AiCrawlerCheckerCrawlerResultResponse":{"type":"object","properties":{"userAgent":{"type":"string","description":"Robots.txt user-agent token for this crawler, exactly as the operator publishes it. Matching is case-insensitive per RFC 9309.","example":"GPTBot"},"displayName":{"type":"string","description":"Name to show. Identical to the user-agent token — a prettified variant would be a second name for the same thing, and the point of showing it is that you can search your own file for it.","example":"GPTBot"},"operator":{"type":"string","description":"The organization that operates this crawler. Where the operator does not document the token at all, the name says so rather than implying they claim it.","example":"OpenAI"},"crawlerPurpose":{"type":"string","description":"What this crawler is FOR — the field that says what blocking it actually costs. 'fetches_pages_to_cite_in_answers': blocking costs VISIBILITY in AI answers. 'collects_training_data': blocking protects your content and costs no visibility; it is a legitimate choice, not a problem. 'builds_search_index_that_grounds_assistants': an ordinary search crawler that an assistant grounds on, so a rule written for search reaches the assistant too. 'policy_token_does_not_crawl': fetches nothing; it carries a policy that applies to other crawlers' fetches. 'seo_or_backlink_analysis', 'renders_link_previews', 'checks_ad_landing_pages': no AI consequence. 'purpose_unknown': we see the token in the wild and could not source what it does — our gap, not a defect in your file.","enum":["fetches_pages_to_cite_in_answers","collects_training_data","builds_search_index_that_grounds_assistants","policy_token_does_not_crawl","seo_or_backlink_analysis","renders_link_previews","checks_ad_landing_pages","purpose_unknown"],"example":"collects_training_data"},"honoursRobotsTxt":{"type":"string","description":"Whether this crawler's operator says it obeys robots.txt. 'not_for_user_initiated_requests' matters most: several operators publish that their rules may not apply when a person pastes a URL and asks about it directly, so a rule against those reads as 'you have asked them not to crawl you', not 'they cannot reach you'.","enum":["yes","no","not_for_user_initiated_requests","not_for_security_or_integrity_checks","unknown"],"example":"yes"},"honoursRobotsTxtEvidence":{"type":"string","description":"How well we know that, which governs how firmly the claim is worded. 'operator_documented': their own words. 'independently_measured': a study, cited with its limits. 'disputed': a credible allegation the operator denies, reported as a dispute and never as a verdict. 'undocumented': nobody has published anything — which is not the same as 'ignores robots.txt'.","enum":["operator_documented","independently_measured","disputed","undocumented"],"example":"operator_documented"},"operatorDocumentationUrl":{"type":"string","description":"The operator's own documentation page. Absent where we could not find one.","example":"https://platform.openai.com/docs/bots"},"operatorDocsLastCheckedOn":{"type":"string","description":"When a person last read that operator page. Operators move tokens and rewrite policies without notice, so this says what was published ON A DATE rather than implying it is current. Claims soften as it ages.","example":"2026-08-13"},"status":{"type":"string","description":"Whether this crawler can reach the site. 'conditional' means the homepage is reachable but a rule closes a recognised content area to it — ordinary operational paths such as /wp-admin/ do not count.","enum":["allowed","blocked","conditional"],"example":"blocked"},"matchingDirective":{"type":"string","description":"The robots.txt line that decided the status, verbatim, or null when no directive matched.","example":"Disallow: /","nullable":true},"matchingDirectiveLine":{"type":"number","description":"1-based line number of that directive, so you can go straight to it in your own file.","example":14},"ruleAudience":{"type":"string","description":"WHO THE RULE WAS WRITTEN FOR, which decides what you would edit. 'names_this_crawler': a group naming this crawler decided it — a policy somebody chose. 'wildcard_catch_all': no group names it and your `User-agent: *` rule decided it — frequently a rule written before the crawler existed, applying to it by accident. A named group REPLACES the wildcard group rather than adding to it (RFC 9309 §2.2.1), so naming a crawler lifts it out of the catch-all. Absent when nothing restricted this crawler.","enum":["names_this_crawler","wildcard_catch_all"],"example":"wildcard_catch_all"}},"required":["userAgent","displayName","operator","crawlerPurpose","honoursRobotsTxt","honoursRobotsTxtEvidence","operatorDocsLastCheckedOn","status","matchingDirective"]},"AiCrawlerCheckerAccessControlReadResponse":{"type":"object","properties":{"state":{"type":"string","description":"Whether we read THIS resource, and if not, why. The same block appears on every resource the check reads — robots.txt, the homepage, llms.txt and llms-full.txt — so read it against the field it sits on rather than assuming robots.txt. 'read' means we retrieved and interpreted it. 'measured_absence' means the server answered 404 and the resource genuinely is not published — a real result, and for robots.txt specifically that is the state RFC 9309 treats as allowing every crawler. 'could_not_read' means we could not reach or interpret it, so nothing beside it may be read as a finding about the site; where that happens to robots.txt, the entire verdict layer is absent rather than assumed. A failed read is not an open site.","enum":["read","measured_absence","could_not_read"],"example":"read"},"reason":{"type":"string","description":"Why the read did not complete. Present only when state is 'could_not_read'. Each value names what WE observed, never a claim about the site. 'access_transport_error' — no answer came back at all. 'access_http_error_status' — the server answered with a status that settles nothing, such as 5xx, 403 or 429. 'access_body_not_robots' — a success status carrying markup rather than a robots.txt, usually a bot-protection page. 'access_body_not_expected_format' — the same thing on llms.txt: a success status carrying markup where a plain-text file was expected, which is a page we could not read and never evidence that the file is absent.","example":"access_http_error_status","enum":["access_transport_error","access_http_error_status","access_body_not_robots","access_body_not_expected_format"]},"httpStatusCode":{"type":"number","description":"HTTP status observed for this file, where one was observed","example":503}},"required":["state"]},"AiCrawlerCheckerRobotsTxtResponse":{"type":"object","properties":{"found":{"type":"boolean","description":"Whether a robots.txt file was read. False covers both 'the site publishes none' and 'we could not read it' — see 'read' for which.","example":true},"read":{"description":"Whether we actually read the file, and if not, why. Check this before interpreting anything else in the response.","allOf":[{"$ref":"#/components/schemas/AiCrawlerCheckerAccessControlReadResponse"}]},"content":{"type":"string","description":"Raw text content of the robots.txt file when fetched successfully","example":"User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /\n"},"statusCode":{"type":"number","description":"HTTP status code of the robots.txt fetch","example":200}},"required":["found","read"]},"AiCrawlerCheckerMetaTagResultResponse":{"type":"object","properties":{"name":{"type":"string","description":"Meta tag name (currently always 'robots')","example":"robots"},"content":{"type":"string","description":"Directive value, e.g. 'noai', 'noimageai', 'noml', 'noimageml'","example":"noai, noimageai"}},"required":["name","content"]},"AiCrawlerCheckerHomepageReadResponse":{"type":"object","properties":{"read":{"description":"Whether we read the homepage, and if not, why. Read this before interpreting `metaTags` or `xRobotsTag` — their absence means nothing until you know the page was fetched.","allOf":[{"$ref":"#/components/schemas/AiCrawlerCheckerAccessControlReadResponse"}]},"metaTags":{"description":"AI-related meta robots directives found on the homepage. ABSENT when the page was not read — never an empty array, which would claim we looked and the page carries none.","type":"array","items":{"$ref":"#/components/schemas/AiCrawlerCheckerMetaTagResultResponse"}},"xRobotsTag":{"type":"string","description":"Value of the X-Robots-Tag response header, where the homepage carried one. Present even on an error status, because a header on an error response is still a real observation.","example":"noai, noimageai"}},"required":["read"]},"AiCrawlerCheckerLlmsTxtResultResponse":{"type":"object","properties":{"read":{"description":"Whether we read the file, and if not, why. There is deliberately no `found` boolean: `read` means the file exists and was read, `measured_absence` means the server returned 404 and it genuinely is not published, and `could_not_read` means we do not know — a refused request, an error status, or markup served with a 200 by a bot-protection page. A single boolean collapsed all of those into 'this site has no llms.txt', which is a claim about the site invented from a failed request.","allOf":[{"$ref":"#/components/schemas/AiCrawlerCheckerAccessControlReadResponse"}]},"sectionCount":{"type":"number","description":"Count of markdown section headers (`# foo` lines)","example":12},"linkCount":{"type":"number","description":"Count of markdown inline links `[text](url)`","example":47},"approxTokens":{"type":"number","description":"Rough token estimate (chars / 4, ceil)","example":1850}},"required":["read"]},"AiCrawlerCheckerIndustryPositionResponse":{"type":"object","properties":{"industryLabel":{"type":"string","description":"Human-readable industry label","example":"SaaS & Tech"},"industryAnyAiCrawlerBlockRate":{"type":"number","description":"Published share of sites in this vertical blocking at least one AI crawler OF ANY KIND, training crawlers included. ⚠️ This is NOT the same quantity as assistantAccess: it is dominated in practice by training crawlers, which do not affect whether assistants can cite a site. Do not read it as 'this share of the industry is unreachable to AI assistants'.","example":25},"figureIsMeasured":{"type":"boolean","description":"False where the published figure is interpolated between studies rather than directly measured for this vertical. The insight sentence marks interpolated figures with '~'.","example":false},"source":{"type":"string","description":"The study the figure comes from.","example":"arxiv:2510.09031"},"sourceDate":{"type":"string","description":"When the source was published or last read. Absent where we do not know — absent means unknown, never current.","example":"2025-10"},"insight":{"type":"string","description":"The figure in a sentence, stating what it counts. It deliberately does not place this site on a scale against it, because the two measure different things.","example":"~25% of saas & tech sites block at least one AI crawler of some kind — most often a training crawler, which does not affect whether assistants can cite them. (arxiv:2510.09031, 2025-10.)"}},"required":["industryLabel","industryAnyAiCrawlerBlockRate","figureIsMeasured","source","insight"]},"AiCrawlerCheckerRecommendationResponse":{"type":"object","properties":{"id":{"type":"string","description":"Stable machine-readable id","example":"answer-time-crawler-swept-up-by-wildcard"},"title":{"type":"string","description":"Short title","example":"A catch-all rule is blocking crawlers it was never written for"},"description":{"type":"string","description":"The detail. Recommendations EXPLAIN what a configuration costs; they suggest undoing one only where the evidence says it was not chosen deliberately — a rule naming a crawler is a decision, a wildcard rule that swept one up is usually not. Blocking a training crawler is never presented as a problem to fix.","example":"OAI-SearchBot and PerplexityBot are blocked by your `User-agent: *` rule, not by any rule naming them. A named group replaces the wildcard group rather than adding to it, so naming a crawler and allowing it lifts it out of that rule and changes nothing else you wrote."},"priority":{"type":"string","description":"Priority, ordered by consequence rather than by how many crawlers are involved.","enum":["high","medium","low"],"example":"high"}},"required":["id","title","description","priority"]},"AiCrawlerCheckerToolResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Normalized domain, as actually analyzed (after normalization)","example":"competlab.com"},"fetchedAt":{"type":"string","description":"ISO-8601 timestamp of when the scan completed","example":"2026-05-16T14:30:00.000Z"},"industry":{"type":"string","description":"Industry echoed back — defaults to 'other' if caller didn't supply one","enum":["news-media","arts-entertainment","law-government","finance-healthcare","saas-tech","ecommerce","other"],"example":"saas-tech"},"scopeOfAnswer":{"type":"string","description":"What this answer covers and what it does not, in one sentence, meant to be shown beside the result rather than buried. Present whenever a verdict was reached.","example":"This reads your robots.txt — the access rules you publish. It does not measure whether AI assistants actually cite you. Rules written into the page itself, such as meta robots and X-Robots-Tag, don't change these verdicts; we report those separately in the homepage results, along with whether we could read that page at all."},"crawlers":{"description":"Every catalog crawler with its derived status — evidence you can check line by line, not a scoreboard. Nothing counts this array; the headline counts assistants. Absent when robots.txt could not be read, because we evaluated no crawler and an empty array would read as 'we looked and found none'.","type":"array","items":{"$ref":"#/components/schemas/AiCrawlerCheckerCrawlerResultResponse"}},"robotsTxt":{"description":"robots.txt fetch result","allOf":[{"$ref":"#/components/schemas/AiCrawlerCheckerRobotsTxtResponse"}]},"homepage":{"description":"The homepage read, carrying the AI meta robots directives and X-Robots-Tag it produced. Both live under ONE read outcome because they come from ONE request — and that request is the one most likely to fail, since robots.txt is a small text file while the homepage is HTML behind whatever bot protection the site runs. Check `homepage.read` before concluding anything from what is or is not here: an unread page used to publish as an empty tag list plus a missing header, which reads as two findings agreeing that the site has no page-level directives when it is one failure counted twice.","allOf":[{"$ref":"#/components/schemas/AiCrawlerCheckerHomepageReadResponse"}]},"llmsTxt":{"description":"/llms.txt — the read outcome, and stats only where we read one.","allOf":[{"$ref":"#/components/schemas/AiCrawlerCheckerLlmsTxtResultResponse"}]},"llmsFullTxt":{"description":"/llms-full.txt — the read outcome, and stats only where we read one.","allOf":[{"$ref":"#/components/schemas/AiCrawlerCheckerLlmsTxtResultResponse"}]},"assistantAccess":{"description":"THE HEADLINE: whether each AI assistant can fetch this site's pages, with the crawlers that decided each verdict named beside it. Count this array to say '4 of 6' — no count is stored anywhere, so a number and the names behind it cannot disagree. There is deliberately no aggregate score: a weighted 0-100 has no unit, no defensible denominator, and moves when our crawler catalog changes rather than when the site does. Absent when robots.txt could not be read.","type":"array","items":{"$ref":"#/components/schemas/AssistantAccessResponse"}},"modelTrainingAccess":{"description":"Whether each model operator may use this site's content for training — reported as a NEUTRAL fact, never as a problem. Blocking a training crawler usually protects content at no cost to whether assistants can cite the site, because the two are governed by different crawlers, and many well-advised sites block training deliberately. ⚠️ THE EXCEPTION IS MECHANICAL AND MUST BE CHECKED, NOT ASSUMED: a few tokens govern training AND an assistant at once — Google documents `Google-Extended` as controlling both model training and grounding in Gemini Apps. Where a token under `decidedByCrawlers` here also appears under `assistantAccess[].decidedByCrawlers`, that block DOES cost reach. Match on `userAgentToken` before repeating the general rule. Absent when robots.txt could not be read.","type":"array","items":{"$ref":"#/components/schemas/ModelTrainingAccessResponse"}},"industryPosition":{"description":"A published industry figure for context, with its source. Read the field descriptions before comparing it to anything — it counts a different quantity from assistantAccess. Absent when robots.txt could not be read.","allOf":[{"$ref":"#/components/schemas/AiCrawlerCheckerIndustryPositionResponse"}]},"recommendations":{"description":"What to do, ordered by consequence. Absent when robots.txt could not be read — advice built on a file we never saw could break a working configuration.","type":"array","items":{"$ref":"#/components/schemas/AiCrawlerCheckerRecommendationResponse"}}},"required":["domain","fetchedAt","industry","robotsTxt","homepage","llmsTxt","llmsFullTxt"]},"PTAiCrawlerCheckerRequestDto":{"type":"object","properties":{"domain":{"type":"string","description":"Target domain to check. Accepts a bare hostname or a full URL — normalized (lowercased, scheme and path stripped) before checking.","example":"stripe.com"},"industry":{"type":"string","description":"Industry context used to tailor crawler recommendations and benchmarks. Defaults to \"other\" when omitted.","enum":["news-media","arts-entertainment","law-government","finance-healthcare","saas-tech","ecommerce","other"],"example":"saas-tech"}},"required":["domain"]},"SitemapVisualizerSitemapInfoResponse":{"type":"object","properties":{"url":{"type":"string","description":"Absolute URL of the fetched sitemap resource","example":"https://competlab.com/sitemap.xml"},"type":{"type":"string","description":"Sitemap resource shape","enum":["index","urlset"],"example":"urlset"},"urlCount":{"type":"number","description":"Number of URLs from this sitemap included in the analysis, which covers at most 10,000 URLs in total. A row of type 'index' carries the total for the sitemaps it lists, which are also listed individually — so summing every row double-counts. For the analysis-wide total use `metrics.totalUrls`.","example":247},"actualUrlCount":{"type":"number","description":"Number of URLs this sitemap declares, before the 10,000-URL analysis limit. On a row of type 'index', the total its own children declare.","example":247}},"required":["url","type","urlCount","actualUrlCount"]},"SitemapVisualizerMetricsResponse":{"type":"object","properties":{"totalUrls":{"type":"number","description":"Count of URLs analyzed (post 10k-cap)","example":247},"totalActualUrls":{"type":"number","description":"True upstream URL count before the 10k cap","example":247},"totalSitemaps":{"type":"number","description":"Number of sitemap resources actually read, counting both 'index' and 'urlset' rows. Never what the site declares — anything the scan could not read is in `unreadSitemaps` instead.","example":3},"categoriesFound":{"type":"number","description":"Number of distinct content categories with at least one URL","example":6},"maxDepth":{"type":"number","description":"Maximum path depth observed","example":4},"avgDepth":{"type":"number","description":"Average pathname depth across all URLs. Null when urls[] is empty.","nullable":true,"example":2.6}},"required":["totalUrls","totalActualUrls","totalSitemaps","categoriesFound","maxDepth","avgDepth"]},"SitemapVisualizerCategorizedUrlResponse":{"type":"object","properties":{"url":{"type":"string","description":"Absolute URL of the page","example":"https://competlab.com/blog/competitive-intelligence-guide"},"category":{"type":"string","description":"Content-category classification (kebab-case)","enum":["blog","docs","tools","landing","legal","case-studies","comparison","integrations","changelog","webinars","careers","programmatic","other"],"example":"blog"},"depth":{"type":"number","description":"Path depth — segment count of URL.pathname after filtering empty parts","example":2},"lastmod":{"type":"string","description":"Value of <lastmod> from the source XML if present, else null","nullable":true,"example":"2026-04-12"}},"required":["url","category","depth","lastmod"]},"SitemapVisualizerCategoryBreakdownResponse":{"type":"object","properties":{"count":{"type":"number","description":"Number of URLs in this category","example":47},"percentage":{"type":"number","description":"Percentage of total URLs (0-100, one decimal place)","example":18.4}},"required":["count","percentage"]},"SitemapVisualizerCategoriesResponse":{"type":"object","properties":{"blog":{"description":"Blog posts and editorial content","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"docs":{"description":"Product documentation","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"tools":{"description":"Free tools and utilities","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"landing":{"description":"Landing / marketing pages","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"legal":{"description":"Legal pages — privacy, terms, DPA, etc.","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"case-studies":{"description":"Customer case studies","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"comparison":{"description":"Comparison pages (X vs Y)","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"integrations":{"description":"Integration pages","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"changelog":{"description":"Changelog / release notes","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"webinars":{"description":"Webinars and events","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"careers":{"description":"Careers / hiring pages","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"programmatic":{"description":"Templated pages generated from a database or pattern — per-item catalog entries, reference tables. Assigned to a group of 25 or more sibling URLs under one parent path whose slugs are machine-generated, never to a single URL. Describes how the pages are generated, not their purpose or quality. Example URLs ship in `insights.sampleUrlsByCategory.programmatic`.","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]},"other":{"description":"Uncategorized pages","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoryBreakdownResponse"}]}},"required":["blog","docs","tools","landing","legal","case-studies","comparison","integrations","changelog","webinars","careers","programmatic","other"]},"SitemapVisualizerValidationCheckResponse":{"type":"object","properties":{"passed":{"type":"boolean","description":"Whether the validation check passed","example":true},"message":{"type":"string","description":"Human-readable reason","example":"Found valid sitemap at /sitemap.xml"}},"required":["passed","message"]},"SitemapVisualizerValidationResultResponse":{"type":"object","properties":{"sitemapFound":{"description":"Whether a sitemap was discovered (via /sitemap.xml, robots.txt, or provided URL)","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerValidationCheckResponse"}]},"validXml":{"description":"Whether the discovered XML parsed successfully","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerValidationCheckResponse"}]},"nonEmpty":{"description":"Whether the sitemap contained at least one URL","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerValidationCheckResponse"}]},"validIndex":{"description":"Whether sitemap index referenced valid child sitemaps (if applicable)","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerValidationCheckResponse"}]}},"required":["sitemapFound","validXml","nonEmpty","validIndex"]},"SitemapVisualizerFreshnessBucketsResponse":{"type":"object","properties":{"fresh":{"type":"number","description":"URLs last modified within the last 3 months (0-90 days)","example":32},"recent":{"type":"number","description":"URLs last modified 3-6 months ago (90-180 days)","example":18},"aging":{"type":"number","description":"URLs last modified 6-12 months ago (180-360 days)","example":41},"stale":{"type":"number","description":"URLs last modified more than a year ago (>360 days)","example":122},"noDate":{"type":"number","description":"URLs missing the <lastmod> tag","example":34}},"required":["fresh","recent","aging","stale","noDate"]},"SitemapVisualizerDepthDistributionResponse":{"type":"object","properties":{"0":{"type":"number","description":"URLs at path depth 0 (home pages)","example":1},"1":{"type":"number","description":"URLs at path depth 1","example":24},"2":{"type":"number","description":"URLs at path depth 2","example":87},"3":{"type":"number","description":"URLs at path depth 3","example":92},"4+":{"type":"number","description":"URLs at path depth 4 or deeper","example":43}},"required":["0","1","2","3","4+"]},"SitemapVisualizerInsightsResponse":{"type":"object","properties":{"freshness":{"description":"URL counts grouped by how recently each page was last modified","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerFreshnessBucketsResponse"}]},"depthDistribution":{"description":"URL count grouped by path depth (0 = home, '4+' = 4+ segments)","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerDepthDistributionResponse"}]},"stalePageCount":{"type":"number","description":"Count of URLs older than ~6 months (180 days). Null when no <lastmod> data is present at all.","nullable":true,"example":163},"sampleUrlsByCategory":{"type":"object","description":"Up to N sample URLs per populated category, largest categories first. Keys are content categories; absent keys mean the category had zero URLs.","additionalProperties":{"type":"array","items":{"type":"string"}},"example":{"blog":["https://competlab.com/blog/competitive-intelligence-guide","https://competlab.com/blog/ai-visibility-tracking"],"docs":["https://competlab.com/docs/getting-started"],"case-studies":["https://competlab.com/case-studies/acme"]}},"summary":{"type":"string","description":"Plain-language read of what this sitemap shows, in 1-3 sentences. The same sitemap always produces the same text.","example":"Content leans towards landing (48% of URLs). Active comparison-page SEO (74 pages). Strong social proof (113 case studies)."}},"required":["freshness","depthDistribution","stalePageCount","sampleUrlsByCategory","summary"]},"SitemapVisualizerToolResponse":{"type":"object","properties":{"domain":{"type":"string","description":"Normalized domain, as actually analyzed (after normalization)","example":"competlab.com"},"fetchedAt":{"type":"string","description":"ISO-8601 timestamp of scan completion","example":"2026-05-16T14:30:00.000Z"},"status":{"type":"string","description":"Overall outcome of the scan — 'ok'/'partial' for success, 'not-found'/'access-denied'/'invalid' as first-class envelope states. 'partial' means the scan did not cover the whole corpus: either a limit stopped it early, or a sitemap the site declares could not be read — check `truncated` and `unreadSitemaps` for which. 'invalid' means a document was retrieved and could not be parsed as XML, never that nothing arrived.","enum":["ok","partial","not-found","access-denied","invalid"],"example":"ok"},"source":{"type":"string","description":"Where discovery started, not the only place it looked. Every scan reads both the conventional /sitemap.xml and every Sitemap: directive robots.txt declares, so a site publishing several sitemaps returns all of them; `sitemaps` is the authoritative list of what was actually read. 'sitemap-xml' means the conventional path returned a sitemap; 'robots-txt' that it did not and robots.txt declared at least one instead — which may itself have failed to load, so check `sitemaps`; 'provided' that you supplied the URL; 'none' that neither source yielded anything.","enum":["sitemap-xml","robots-txt","provided","none"],"example":"sitemap-xml"},"sitemaps":{"description":"Every sitemap resource actually read, in the order the scan read them. When `urls` is returned it is the concatenation of each 'urlset' row's URLs in this same order. A sitemap that could not be read is not here: if the site declared it in robots.txt it is named in `unreadSitemaps`, and if it was a child listed inside an index, `validation.validIndex` reports that the index names children the scan could not retrieve.","type":"array","items":{"$ref":"#/components/schemas/SitemapVisualizerSitemapInfoResponse"}},"truncated":{"type":"boolean","description":"True if the scan stopped short of the whole corpus: more than 10,000 URLs, its ceiling of 20 sitemaps under any one index or 20 declared in robots.txt, sitemap indexes nested deeper than it walks, or a declared sitemap it could not read. The counts below describe what was read, not the whole site.","example":false},"unreadSitemaps":{"description":"Sitemaps declared in this site's robots.txt that the scan could not read: the URL is not fetchable as written (relative rather than absolute, or a host we will not follow), a redirect led off the site's own domain, the server refused it, or nothing came back. Each one is a promise robots.txt makes and does not keep — search engines and AI crawlers read the same directives, so it is worth fixing on your own site. Absent when every declared sitemap was read. Two cases are deliberately not listed: one that answered 404, which is a confirmed absence rather than an unread document; and one the scan skipped after using up its own budget, which `truncated` alone covers. Capped at 20 entries.","example":["https://example.com/sitemap-archive.xml"],"type":"array","items":{"type":"string"}},"unreadSitemapCount":{"type":"number","description":"How many declared sitemaps went unread. Absent when none did. This is the number to report — it is not `unreadSitemaps.length`, which is capped at 20 and omits any value carrying a scheme the scan will not repeat back. A scan can report unread sitemaps with an empty list, and that still means the site's robots.txt is at fault rather than the scan's limits.","example":2},"accessDenied":{"type":"boolean","description":"True if the conventional /sitemap.xml returned 401/403","example":false},"metrics":{"description":"Quantitative metrics — URL counts, sitemap count, category count, depth stats","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerMetricsResponse"}]},"urls":{"description":"Raw categorized URLs. Present only when request had includeUrls: true. Capped at 10,000; check `truncated` flag.","type":"array","items":{"$ref":"#/components/schemas/SitemapVisualizerCategorizedUrlResponse"}},"categories":{"description":"Per-category breakdown. Every category key is populated (count may be 0).","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerCategoriesResponse"}]},"validation":{"description":"Quality checks for sitemap discovery and parsing","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerValidationResultResponse"}]},"insights":{"description":"Interpreted findings — freshness, depth distribution, stale-page count, category samples, summary","allOf":[{"$ref":"#/components/schemas/SitemapVisualizerInsightsResponse"}]}},"required":["domain","fetchedAt","status","source","sitemaps","truncated","accessDenied","metrics","categories","validation","insights"]},"PTSitemapVisualizerRequestDto":{"type":"object","properties":{"domain":{"type":"string","description":"Target domain whose sitemap to analyze. Accepts a bare hostname or a full URL — normalized (lowercased, scheme and path stripped) before analysis.","example":"stripe.com"},"sitemapUrl":{"type":"string","description":"Explicit sitemap URL to fetch directly, short-circuiting /sitemap.xml then robots.txt discovery. Must be a full http:// or https:// URL with a public hostname.","example":"https://stripe.com/sitemap.xml"},"includeUrls":{"type":"boolean","description":"When true, include the full raw `urls[]` list in the response. Defaults to false, which returns a lean insights-only payload without the URL list.","example":false}},"required":["domain"]},"AgentAdoptionScannerResponse":{"type":"object","properties":{"name":{"type":"string","description":"Scanner brand string (constant per implementation)","example":"CompetLab Agent-Adoption Scanner"},"version":{"type":"string","description":"Semver-style version tag of the scanner emitting this response","example":"0.8.0"}},"required":["name","version"]},"AgentAdoptionNextLevelResponse":{"type":"object","properties":{"level":{"type":"number","description":"Target level number","example":2},"levelName":{"type":"string","description":"Target level name","example":"AI-Aware"},"requirements":{"type":"array","description":"Checks that must pass to advance (AND semantics)","example":["content-signals"],"items":{"type":"string","enum":["robots-txt-exists","sitemap-exists","link-headers","ai-bot-rules","content-signals","web-bot-auth","robots-allow-all","llms-txt-exists","llms-txt-valid","llms-txt-size","llms-txt-has-optional-section","markdown-url-support","markdown-negotiation","rendering-strategy","page-size-html","http-status-codes","redirect-behavior","agents-md-detection","cache-header-hygiene","api-catalog","oauth-discovery","oauth-protected-resource","mcp-server-card","a2a-agent-card","agent-skills"]}}},"required":["level","levelName","requirements"]},"AgentAdoptionFixPromptResponse":{"type":"object","properties":{"full":{"type":"string","description":"Full copy-paste remediation prompt (Markdown) for your AI coding assistant","example":"# Fix: Add llms.txt\n\nCreate a `/llms.txt` file at your site root..."}},"required":["full"]},"AgentAdoptionEvidenceRequestResponse":{"type":"object","properties":{"url":{"type":"string","description":"Request URL","example":"https://competlab.com/robots.txt"},"method":{"type":"string","description":"HTTP method","example":"GET"},"headers":{"type":"object","description":"Sanitized request headers (auth/session headers stripped)","example":{"user-agent":"CompetLab-AgentAdoptionScanner/0.8.0"},"additionalProperties":{"type":"string"}}},"required":["url","method"]},"AgentAdoptionEvidenceResponsePayload":{"type":"object","properties":{"status":{"type":"number","description":"HTTP status code","example":200},"statusText":{"type":"string","description":"HTTP status text","example":"OK"},"headers":{"type":"object","description":"Sanitized response headers (auth/session headers stripped)","example":{"content-type":"text/plain","cache-control":"max-age=3600"},"additionalProperties":{"type":"string"}},"bodyPreview":{"type":"string","description":"Truncated body preview for the response","example":"User-agent: *\nAllow: /\n\nSitemap: https://competlab.com/sitemap.xml"}},"required":["status","statusText"]},"AgentAdoptionEvidenceFindingResponse":{"type":"object","properties":{"outcome":{"type":"string","description":"Outcome of the trace step","enum":["positive","negative","neutral"],"example":"positive"},"summary":{"type":"string","description":"Short summary of the finding","example":"robots.txt found with valid user-agent declarations"}},"required":["outcome","summary"]},"AgentAdoptionEvidenceResponse":{"type":"object","properties":{"action":{"type":"string","description":"Trace action kind","enum":["fetch","parse","conclude"],"example":"fetch"},"label":{"type":"string","description":"Human-readable label for this trace step","example":"Fetch robots.txt"},"request":{"description":"Request details (only for fetch actions)","allOf":[{"$ref":"#/components/schemas/AgentAdoptionEvidenceRequestResponse"}]},"response":{"description":"Response details (only for fetch actions)","allOf":[{"$ref":"#/components/schemas/AgentAdoptionEvidenceResponsePayload"}]},"finding":{"description":"Finding summary (only for parse/conclude actions)","allOf":[{"$ref":"#/components/schemas/AgentAdoptionEvidenceFindingResponse"}]}},"required":["action","label"]},"AgentAdoptionCheckResponse":{"type":"object","properties":{"id":{"type":"string","description":"Check identifier, kebab-case (matches 25-check v1 scorecard)","enum":["robots-txt-exists","sitemap-exists","link-headers","ai-bot-rules","content-signals","web-bot-auth","robots-allow-all","llms-txt-exists","llms-txt-valid","llms-txt-size","llms-txt-has-optional-section","markdown-url-support","markdown-negotiation","rendering-strategy","page-size-html","http-status-codes","redirect-behavior","agents-md-detection","cache-header-hygiene","api-catalog","oauth-discovery","oauth-protected-resource","mcp-server-card","a2a-agent-card","agent-skills"],"example":"robots-txt-exists"},"category":{"type":"string","description":"Top-level category this check belongs to","enum":["discoverability","accessControl","contentReadability","agentEndpoints"],"example":"discoverability"},"status":{"type":"string","description":"Check outcome. 'pass' and 'fail' are measured verdicts about the site. 'neutral' is informational, or a check whose dependency was skipped. 'could-not-measure' means WE could not look — a server error, a timeout, a bot challenge — and says nothing about the site. Both 'neutral' and 'could-not-measure' are excluded from the score, from the numerator and the denominator alike.","enum":["pass","fail","neutral","could-not-measure"],"example":"pass"},"scored":{"type":"boolean","description":"Whether the check is scored. false = informational — excluded from both numerator and denominator.","example":true},"weight":{"type":"number","description":"Weight on the 0-10 tier scale (Critical 10 / High 7 / Medium 4 / Low 2). 0 for informational checks.","example":10},"message":{"type":"string","description":"Declarative, verdict-phrased — 'what is the verdict + why'","example":"robots.txt found at https://competlab.com/robots.txt with 3 user-agent rules"},"description":{"type":"string","description":"Constant per-check one-sentence 'what this measures' (scan-invariant)","example":"Verifies a /robots.txt file exists and is fetchable."},"durationMs":{"type":"number","description":"Per-check duration in milliseconds","example":142},"dependsOn":{"type":"array","description":"Check IDs this check depends on — skipped when deps did not pass","example":[],"items":{"type":"string","enum":["robots-txt-exists","sitemap-exists","link-headers","ai-bot-rules","content-signals","web-bot-auth","robots-allow-all","llms-txt-exists","llms-txt-valid","llms-txt-size","llms-txt-has-optional-section","markdown-url-support","markdown-negotiation","rendering-strategy","page-size-html","http-status-codes","redirect-behavior","agents-md-detection","cache-header-hygiene","api-catalog","oauth-discovery","oauth-protected-resource","mcp-server-card","a2a-agent-card","agent-skills"]}},"specUrls":{"description":"Authoritative spec URLs (RFCs, W3C drafts, vendor docs). Always present so consumers can link to docs regardless of verdict.","example":["https://www.rfc-editor.org/rfc/rfc9309","https://developers.google.com/search/docs/crawling-indexing/robots/intro"],"type":"array","items":{"type":"string"}},"details":{"type":"object","description":"Typed per-check structured metrics. Shape varies by check id (discriminated by 'kind' === check.id). Undefined when the check has no meaningful programmatic metrics beyond `message`.","additionalProperties":true,"example":{"kind":"robots-txt-exists","userAgentCount":3,"sitemapDirectives":["https://competlab.com/sitemap.xml"]}},"fixPrompt":{"description":"Copy-paste remediation prompt. Emitted only when the request sets includeFixPrompts: true and the check is failed and scored.","allOf":[{"$ref":"#/components/schemas/AgentAdoptionFixPromptResponse"}]},"evidence":{"description":"Full HTTP trace. Emitted only when the request sets debugMode: true.","type":"array","items":{"$ref":"#/components/schemas/AgentAdoptionEvidenceResponse"}}},"required":["id","category","status","scored","weight","message","description","durationMs","dependsOn","specUrls"]},"AgentAdoptionCategoryReportResponse":{"type":"object","properties":{"category":{"type":"string","description":"Category identifier","enum":["discoverability","accessControl","contentReadability","agentEndpoints"],"example":"discoverability"},"description":{"type":"string","description":"One-sentence customer-value description of what the category covers","example":"Whether AI agents can find your site through standard discovery mechanisms (robots.txt, sitemaps, link headers)."},"score":{"type":"number","description":"0..100 integer category subscore","example":75},"passed":{"type":"number","description":"Count of scored checks that passed in this category","example":5},"failed":{"type":"number","description":"Count of scored checks that failed in this category","example":1},"neutral":{"type":"number","description":"Count of checks that were neutral / informational","example":0},"couldNotMeasure":{"type":"number","description":"Count of checks WE could not run — a server error, a timeout, a bot challenge. These say nothing about the site. This names the score's universe: 'score' is a proportion over passed + failed only, so a category with any check here was scored over fewer checks than a full one and the two scores are not comparable.","example":0},"checks":{"description":"All checks belonging to this category","type":"array","items":{"$ref":"#/components/schemas/AgentAdoptionCheckResponse"}}},"required":["category","description","score","passed","failed","neutral","couldNotMeasure","checks"]},"AgentAdoptionClusterVerdictResponse":{"type":"object","properties":{"name":{"type":"string","description":"Cluster verdict name","enum":["htmlPath","spaRenderingCap","noViablePathCap"],"example":"htmlPath"},"kind":{"type":"string","description":"Verdict kind. 'coefficient' scales per-check weights; 'cap' clamps the final overall score.","enum":["coefficient","cap"],"example":"coefficient"},"triggered":{"type":"boolean","description":"Whether the verdict fired for this scan","example":true},"coefficient":{"type":"number","description":"Present when kind === 'coefficient'. Multiplier in 0..1 — 1 = no penalty.","example":1},"appliesTo":{"type":"array","description":"Present when kind === 'coefficient'. Check IDs whose weight is scaled.","example":["rendering-strategy","page-size-html"],"items":{"type":"string","enum":["robots-txt-exists","sitemap-exists","link-headers","ai-bot-rules","content-signals","web-bot-auth","robots-allow-all","llms-txt-exists","llms-txt-valid","llms-txt-size","llms-txt-has-optional-section","markdown-url-support","markdown-negotiation","rendering-strategy","page-size-html","http-status-codes","redirect-behavior","agents-md-detection","cache-header-hygiene","api-catalog","oauth-discovery","oauth-protected-resource","mcp-server-card","a2a-agent-card","agent-skills"]}},"capScore":{"type":"number","description":"Present when kind === 'cap' AND triggered. Final score is forced ≤ this value.","example":50},"message":{"type":"string","description":"One-line human-readable verdict","example":"Standard HTML path detected — no rendering penalties applied."}},"required":["name","kind","triggered","message"]},"AgentAdoptionFetchCountersResponse":{"type":"object","properties":{"total":{"type":"number","description":"Total fetches attempted","example":28},"success":{"type":"number","description":"2xx status codes","example":22},"wafBlocked":{"type":"number","description":"403 + 429 responses (WAF challenge signals)","example":0},"notFound":{"type":"number","description":"404-only responses","example":5},"failed":{"type":"number","description":"Network / TLS / timeout / other 4xx-5xx failures","example":1}},"required":["total","success","wafBlocked","notFound","failed"]},"AgentAdoptionHeavyFetchCountersResponse":{"type":"object","properties":{"total":{"type":"number","description":"Total heavy-fetch operations attempted","example":2},"success":{"type":"number","description":"Successful heavy-fetch operations","example":2},"unavailable":{"type":"number","description":"Heavy-fetch operations the service could not complete (upstream unavailable or timed out)","example":0}},"required":["total","success","unavailable"]},"AgentAdoptionCountersResponse":{"type":"object","properties":{"fetches":{"description":"Per-scan lightweight HTTP fetch counters","allOf":[{"$ref":"#/components/schemas/AgentAdoptionFetchCountersResponse"}]},"heavyFetchCalls":{"description":"Per-scan heavy-fetch counters (used when the lightweight fetch is blocked or when JavaScript-rendered HTML is required)","allOf":[{"$ref":"#/components/schemas/AgentAdoptionHeavyFetchCountersResponse"}]}},"required":["fetches","heavyFetchCalls"]},"AgentAdoptionMetaResponse":{"type":"object","properties":{"checksEvaluated":{"type":"number","description":"Count of checks that produced a result of any kind (pass, fail, neutral, or could-not-measure). Dependency-skipped checks are counted.","example":25},"checksSkipped":{"type":"number","description":"Count of v1 checks that did not run at all in this scan (e.g., future checks not yet implemented). Does not include dependency-skipped checks.","example":0},"scanDurationMs":{"type":"number","description":"Total wall-clock scan duration in milliseconds","example":8420},"counters":{"description":"Per-scan counters — fetches + upstream fetch-pipeline calls. Visible in all modes.","allOf":[{"$ref":"#/components/schemas/AgentAdoptionCountersResponse"}]}},"required":["checksEvaluated","checksSkipped","scanDurationMs","counters"]},"AgentAdoptionToolResponse":{"type":"object","properties":{"specVersion":{"type":"string","description":"Spec version this output conforms to","example":"1.0.0"},"profile":{"type":"string","description":"Profile name this scanner output reflects","example":"b2b-saas"},"scanner":{"description":"Scanner identity — name + version","allOf":[{"$ref":"#/components/schemas/AgentAdoptionScannerResponse"}]},"domain":{"type":"string","description":"Normalized domain, as actually requested","example":"competlab.com"},"finalUrl":{"type":"string","description":"Final URL after redirects (from the upstream homepage fetch)","example":"https://competlab.com/"},"scannedAt":{"type":"string","description":"ISO-8601 timestamp of when the scan completed","example":"2026-05-16T14:30:00.000Z"},"score":{"type":"number","description":"0..100 integer overall score. Informational checks excluded; capped when a cluster cap verdict fires. Decorative — does not drive `level`.","example":72},"level":{"type":"number","description":"Numeric level tier. Gate-based — see nextLevel.requirements.","example":2},"levelName":{"type":"string","description":"Human-readable level name paired with level","example":"AI-Aware"},"nextLevel":{"description":"Next-level gate — absent when at top of known ladder (L3+ gates unknown in v1)","allOf":[{"$ref":"#/components/schemas/AgentAdoptionNextLevelResponse"}]},"categoryReports":{"description":"4 category reports — locked order: discoverability → accessControl → contentReadability → agentEndpoints","type":"array","items":{"$ref":"#/components/schemas/AgentAdoptionCategoryReportResponse"}},"clusters":{"description":"Cross-check scoring-layer verdicts (coefficients + caps). Always present — even non-triggered. Order locked: htmlPath → spaRenderingCap → noViablePathCap.","type":"array","items":{"$ref":"#/components/schemas/AgentAdoptionClusterVerdictResponse"}},"meta":{"description":"Envelope meta — scan duration, evaluated/skipped check counts, counters","allOf":[{"$ref":"#/components/schemas/AgentAdoptionMetaResponse"}]}},"required":["specVersion","profile","scanner","domain","finalUrl","scannedAt","score","level","levelName","categoryReports","clusters","meta"]},"AgentAdoptionScanErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable error code","enum":["homepage_fetch_failed","scan_failed"],"example":"homepage_fetch_failed"},"message":{"type":"string","description":"Human-readable error message","example":"Homepage fetch failed"}},"required":["code","message"]},"AgentAdoptionScanResponse":{"type":"object","properties":{"id":{"type":"string","description":"Scan ID (24-char hex identifier). Use to poll GET /scans/:scanId.","example":"6638e9a1b2c3d4e5f6a7b8c9"},"status":{"type":"string","description":"Current lifecycle state. Poll until `completed` or `failed`.","enum":["queued","running","completed","failed"],"example":"queued"},"createdAt":{"type":"string","description":"ISO-8601 timestamp when the scan was created.","example":"2026-05-17T14:30:00.000Z"},"expiresAt":{"type":"string","description":"ISO-8601 timestamp when the doc auto-deletes (createdAt + 24h). After this, GET returns 404.","example":"2026-05-18T14:30:00.000Z"},"startedAt":{"type":"string","description":"ISO-8601 timestamp when the background runner began scanning. Set once status leaves `queued`.","example":"2026-05-17T14:30:00.412Z"},"completedAt":{"type":"string","description":"ISO-8601 timestamp when the scan reached a terminal state (completed or failed).","example":"2026-05-17T14:30:45.218Z"},"result":{"description":"Full canonical agent-adoption result. Present only when `status === \"completed\"`.","allOf":[{"$ref":"#/components/schemas/AgentAdoptionToolResponse"}]},"error":{"description":"Error details. Present only when `status === \"failed\"`.","allOf":[{"$ref":"#/components/schemas/AgentAdoptionScanErrorResponse"}]}},"required":["id","status","createdAt","expiresAt"]},"PTAgentAdoptionRequestDto":{"type":"object","properties":{"domain":{"type":"string","description":"Target domain to run the Agent Adoption Check against. Accepts a bare hostname or a full URL — normalized (lowercased, scheme and path stripped) before scanning.","example":"stripe.com"},"debugMode":{"type":"boolean","description":"When true, include the full HTTP trace (`evidence`) for every check in the response. Defaults to false.","example":false},"includeFixPrompts":{"type":"boolean","description":"When true, include a copy-paste remediation prompt (`fixPrompt`) for each failed, scored check. Defaults to false.","example":false}},"required":["domain"]},"FetchUrlCleanStatsResponse":{"type":"object","properties":{"originalSize":{"type":"number","description":"Original HTML body size in bytes (pre-cleanup)","example":248532},"cleanedSize":{"type":"number","description":"Cleaned HTML body size in bytes (post-cleanup)","example":41218},"reductionPercent":{"type":"number","description":"Reduction percentage (0–100, one decimal place)","example":83.4},"originalSizeKB":{"type":"number","description":"Original size in kilobytes (convenience)","example":242.7},"cleanedSizeKB":{"type":"number","description":"Cleaned size in kilobytes (convenience)","example":40.3},"savedKB":{"type":"number","description":"Bytes saved expressed as kilobytes (convenience)","example":202.4}},"required":["originalSize","cleanedSize","reductionPercent","originalSizeKB","cleanedSizeKB","savedKB"]},"FetchUrlToolResponse":{"type":"object","properties":{"statusCode":{"type":"number","description":"HTTP status code returned by the target after the cascade resolved","example":200},"body":{"type":"string","description":"Response body. Present when the request had `bodyNeeded: true`.","example":"<!DOCTYPE html><html>…</html>"},"headers":{"type":"object","description":"Response headers, lower-cased keys. Present whenever the request had `headersNeeded: true` — including when nothing could be read, in which case it is an empty object rather than a missing field. Read `headersAvailable` to tell the two apart; a `{}` here means we asked and the target revealed nothing, not that we did not ask.","additionalProperties":{"type":"string"},"example":{"content-type":"text/html; charset=utf-8","strict-transport-security":"max-age=63072000"}},"headersAvailable":{"type":"boolean","description":"Honest-degradation discriminator, and the ONLY field to branch on. `true` when headers were observed end-to-end; `false` when the target site uses advanced behavioral fingerprinting that prevents header access — `headers` is then an empty object, still present. Testing whether `headers` exists never distinguishes the two cases.","example":true},"contentType":{"type":"string","description":"Content-type of the resolved response. Present when the request had `bodyNeeded: true`.","enum":["text/html","text/plain","application/xml","application/json"],"example":"text/html"},"cleanStats":{"description":"HTML cleanup reduction stats. Present when the request had `cleanHtml: true` AND the resolved content-type is `text/html`.","allOf":[{"$ref":"#/components/schemas/FetchUrlCleanStatsResponse"}]},"finalUrl":{"type":"string","description":"Resolved URL after any redirects","example":"https://stripe.com/"},"durationMs":{"type":"number","description":"End-to-end fetch duration in milliseconds","example":1842}},"required":["statusCode","finalUrl","durationMs"]},"PTFetchUrlRequestDto":{"type":"object","properties":{"url":{"type":"string","description":"Target URL to fetch. Must use http:// or https:// and resolve to a public host.","example":"https://stripe.com"},"bodyNeeded":{"type":"boolean","description":"Include `body` and `contentType` in the response. Defaults to service-controlled value when omitted.","example":true},"headersNeeded":{"type":"boolean","description":"Include `headers` and `headersAvailable` in the response. Defaults to service-controlled value when omitted. When the target site uses advanced behavioral fingerprinting, `headersAvailable` is `false` and `headers` is an empty object — present, not missing. Branch on `headersAvailable`, never on whether `headers` exists.","example":true},"cleanHtml":{"type":"boolean","description":"When `true` and the response content-type is `text/html`, strip HTML noise (scripts, styles, comments) while preserving text content. Significant token-cost reduction for LLM consumption — per-request reduction reported in `cleanStats`. Requires `bodyNeeded`.","example":false},"maxTimeoutMs":{"type":"number","description":"Caller-side timeout budget in milliseconds. Accepted range 1000–120000. Defaults to service-controlled value when omitted.","example":60000,"minimum":1000,"maximum":120000},"bodyMaxBytes":{"type":"number","description":"Per-request response body cap in bytes. Accepted range 1024–104857600 (1 KiB – 100 MiB). Oversize responses are rejected pre-buffer. Defaults to service-controlled value when omitted.","example":5242880,"minimum":1024,"maximum":104857600}},"required":["url"]},"ApiServiceUnavailableErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"Machine-readable code for a temporarily unreachable upstream target.","enum":["target_unreachable"],"example":"target_unreachable"},"message":{"type":"string","description":"Human-readable error message","example":"Target URL could not be reached"},"status":{"type":"number","description":"HTTP status code","example":503}},"required":["code","message","status"]},"ApiServiceUnavailableErrorEnvelope":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/ApiServiceUnavailableErrorResponse"}},"required":["error"]}}}}