{"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":"view","required":false,"in":"query","description":"How much of the response to return. `compact`: each crawler's catalog facts (its purpose and whether a robots.txt rule against it binds) are stated once in `crawlerCatalog` instead of on every verdict that names it. `full`: every row of every list, as stored — large, and meant for export rather than for reading. Omitted, the API's default view applies: `compact` on `/v1`.","schema":{"example":"compact","type":"string","enum":["compact","full"]}},{"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"]}}}},"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 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 for a project: the market map — which companies the AI models recommend in this category, ordered by how often each is named, and where you stand among them — then AI Visibility Scores, mention rates and per-model breakdowns for every company found. 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 was named only below the top 5, or too seldom inside them 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 not scored, `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=`, `provider=` or `promptIndex=`. It is large, and how large depends on the account. An entry is one brand a model named, at about 1,500 characters each — so the block grows with three things at once: how many prompts the project asks (an account setting), how many models answered, and how many companies each answer named. No figure quoted here can stand in for `summary.totalEntries`; read it and size the fetch from it. Google AI Overviews answers additionally carry the overview text and the pages Google cited, which `totalEntries` does not predict and which `brand=` keeps by design. `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, and how large depends on the account. An entry is one brand a model named, at about 1,500 characters each — so the block grows with three things at once: how many prompts the project asks (an account setting), how many models answered, and how many companies each answer named. No figure quoted here can stand in for `summary.totalEntries`; read it and size the fetch from it. Google AI Overviews answers additionally carry the overview text and the pages Google cited, which `totalEntries` does not predict and which the `brand=` filter keeps by design. 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: it keeps at most one brand entry per answer instead of every brand the model named, and none at all on the answers that did not name it. Compare `summary.totalEntries` with the answer count to see the saving on this check. Google AI Overviews answers still carry their overview text and cited pages, which this filter keeps by design.","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":"view","required":false,"in":"query","description":"How much of the response to return. `compact`: `summary.marketMap.brands` is one page of rows — the core by default — with `summary.marketMap.brandsPage` saying where it sits; the customer's own row and every tracked competitor's are always included. Every other field is unchanged, and `untrackedCoreBrands` and `customerStanding` are still read from the whole map. `full`: every row of every list, as stored — large, and meant for export rather than for reading. Omitted, the API's default view applies: `compact` on `/v1`.","schema":{"example":"compact","type":"string","enum":["compact","full"]}},{"name":"mapOffset","required":false,"in":"query","description":"Rows of `summary.marketMap.brands` to skip, in the order the list is stored. Default 0. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":0,"example":0,"type":"number"}},{"name":"mapLimit","required":false,"in":"query","description":"Rows of `summary.marketMap.brands` to return, 1 to 200. Default the core size (`summary.marketMap.coreSize`), and never fewer than 10. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":1,"maximum":200,"example":10,"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 was named only below the top 5, or too seldom inside them 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":"view","required":false,"in":"query","description":"How much of the response to return. `compact`: each row's `summary.competitorRankings` carries its first 10 companies in stored order, then every tracked company and the customer's own row that fall outside them, with `summary.competitorRankingsPage` counting the whole list; `summary.promptMarket.perPrompt` is left out — the dashboard and a check's detail carry it. Every other field is unchanged. There is no offset: the whole list is `view=full`, or the check's detail. `full`: every row of every list, as stored — large, and meant for export rather than for reading. Omitted, the API's default view applies: `compact` on `/v1`.","schema":{"example":"compact","type":"string","enum":["compact","full"]}},{"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":{"readingGuide":{"description":"The rules for reading this response, keyed by the field they govern — a dotted path, where `[]` means any element of that array — and only for fields this response carries. First in either view. Read them before quoting a figure; they say what each field is and is not.","type":"object","additionalProperties":{"type":"string"},"example":{"summary.pages":"Pages the engines RETRIEVED while answering, never pages they cited."}},"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: the market map as it stood at this check under `summary.marketMap`, and each competitor's mention rate and AI Visibility Score across every AI model we query under `summary.competitorRankings` — in the order to render, nothing positional. 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 was named only below the top 5, or too seldom inside them 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, and how large depends on the account. An entry is one brand a model named, at about 1,500 characters each — so the block grows with three things at once: how many prompts the project asks (an account setting), how many models answered, and how many companies each answer named. No figure quoted here can stand in for `summary.totalEntries`; read it and size the fetch from it. Google AI Overviews answers additionally carry the overview text and the pages Google cited, which `totalEntries` does not predict and which the brand filter keeps by design. Then prefer `brand=` (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, and how large depends on the account. An entry is one brand a model named, at about 1,500 characters each — so the block grows with three things at once: how many prompts the project asks (an account setting), how many models answered, and how many companies each answer named. No figure quoted here can stand in for `summary.totalEntries`; read it and size the fetch from it. Google AI Overviews answers additionally carry the overview text and the pages Google cited, which `totalEntries` does not predict and which the `brand=` filter keeps by design. 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: it keeps at most one brand entry per answer instead of every brand the model named, and none at all on the answers that did not name it. Compare `summary.totalEntries` with the answer count to see the saving on this check. Google AI Overviews answers still carry their overview text and cited pages, which this filter keeps by design.","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":"view","required":false,"in":"query","description":"How much of the response to return. `compact`: `summary.marketMap.brands` is one page of rows — the core by default — with `summary.marketMap.brandsPage` saying where it sits; the customer's own row and every tracked competitor's are always included. Every other field is unchanged, and `untrackedCoreBrands` and `customerStanding` are still read from the whole map. `full`: every row of every list, as stored — large, and meant for export rather than for reading. Omitted, the API's default view applies: `compact` on `/v1`.","schema":{"example":"compact","type":"string","enum":["compact","full"]}},{"name":"mapOffset","required":false,"in":"query","description":"Rows of `summary.marketMap.brands` to skip, in the order the list is stored. Default 0. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":0,"example":0,"type":"number"}},{"name":"mapLimit","required":false,"in":"query","description":"Rows of `summary.marketMap.brands` to return, 1 to 200. Default the core size (`summary.marketMap.coreSize`), and never fewer than 10. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":1,"maximum":200,"example":10,"type":"number"}},{"name":"includeSummary","required":false,"in":"query","description":"Whether to return `summary`. Omitted, `/v1` returns it unless `includeAnswers=true` — a read for the answers rarely needs the summary again, and the summary is most of the response — and a param that pages a summary list returns it too. Set it explicitly to override; `false` beside a paging param is refused with `paging_requires_summary`.","schema":{"example":true,"type":"boolean"}},{"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 every tracked competitor are always rows, however many there are, plus up to 3 companies the project does not track (the most recommended of the rest with a reading on the scope), all ordered by how often each is recommended on the latest map (ties are ties, so never break one). Each reading carries `checksAnalysed`, the checks its map pools: `now` is never the latest check alone. 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 was named only below the top 5, or too seldom inside them 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 other companies 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":"view","required":false,"in":"query","description":"How much of the response to return. `compact`: `summary.pages` and `summary.brands` are one page of rows each, with `summary.pagesPage` and `summary.brandsPage` saying where it sits (the customer's own brand row is always included); each `summary.coreHosts[]` lists its pages as `pageUrls`, every one a row of `summary.pages` reachable with `pagesHost`. Every other field is unchanged. `full`: every row of every list, as stored — large, and meant for export rather than for reading. Omitted, the API's default view applies: `compact` on `/v1`.","schema":{"example":"compact","type":"string","enum":["compact","full"]}},{"name":"pagesHost","required":false,"in":"query","description":"Return only the rows of `summary.pages` on this host, matched exactly and case-insensitively; a leading `www.` is ignored on both sides, so `www.g2.com` and `g2.com` are the same host. Use a `summary.coreHosts[].host` to read that host's pages. `summary.pagesPage.total` counts the rows after this filter. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"example":"g2.com","type":"string"}},{"name":"pagesOffset","required":false,"in":"query","description":"Rows of `summary.pages` to skip, in the order the list is stored. Default 0. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":0,"example":0,"type":"number"}},{"name":"pagesLimit","required":false,"in":"query","description":"Rows of `summary.pages` to return, 1 to 100. Default 10. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":1,"maximum":100,"example":10,"type":"number"}},{"name":"brandsOffset","required":false,"in":"query","description":"Rows of `summary.brands` to skip, in the order the list is stored. Default 0. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":0,"example":0,"type":"number"}},{"name":"brandsLimit","required":false,"in":"query","description":"Rows of `summary.brands` to return, 1 to 200. Default 10. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":1,"maximum":200,"example":10,"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":"view","required":false,"in":"query","description":"How much of the response to return. `compact`: `summary.pages` and `summary.brands` are one page of rows each, with `summary.pagesPage` and `summary.brandsPage` saying where it sits (the customer's own brand row is always included); each `summary.coreHosts[]` lists its pages as `pageUrls`, every one a row of `summary.pages` reachable with `pagesHost`. Every other field is unchanged. `full`: every row of every list, as stored — large, and meant for export rather than for reading. Omitted, the API's default view applies: `compact` on `/v1`.","schema":{"example":"compact","type":"string","enum":["compact","full"]}},{"name":"pagesHost","required":false,"in":"query","description":"Return only the rows of `summary.pages` on this host, matched exactly and case-insensitively; a leading `www.` is ignored on both sides, so `www.g2.com` and `g2.com` are the same host. Use a `summary.coreHosts[].host` to read that host's pages. `summary.pagesPage.total` counts the rows after this filter. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"example":"g2.com","type":"string"}},{"name":"pagesOffset","required":false,"in":"query","description":"Rows of `summary.pages` to skip, in the order the list is stored. Default 0. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":0,"example":0,"type":"number"}},{"name":"pagesLimit","required":false,"in":"query","description":"Rows of `summary.pages` to return, 1 to 100. Default 10. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":1,"maximum":100,"example":10,"type":"number"}},{"name":"brandsOffset","required":false,"in":"query","description":"Rows of `summary.brands` to skip, in the order the list is stored. Default 0. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":0,"example":0,"type":"number"}},{"name":"brandsLimit","required":false,"in":"query","description":"Rows of `summary.brands` to return, 1 to 200. Default 10. Pages the compact view: sent without `view` it selects the compact view, and beside `view=full` it is refused with `paging_requires_compact_view`.","schema":{"minimum":1,"maximum":200,"example":10,"type":"number"}},{"name":"includeSummary","required":false,"in":"query","description":"Whether to return `summary`. Omitted, `/v1` returns it unless `includeAnswers=true` — a read for the answers rarely needs the summary again, and the summary is most of the response — and a param that pages a summary list returns it too. Set it explicitly to override; `false` beside a paging param is refused with `paging_requires_summary`.","schema":{"example":true,"type":"boolean"}},{"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 and what it means). 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`, `contains` and `tickets` 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 run finishes within 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.\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. A hub `diagnosis` row that earned a deep-dive 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`). A row without one has no deep-dive this edition and its `note` says why; request a section only for a pointer that is present. Valid sections: `hub`, `competitors`, the fourteen `deep-` 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**What the edition recommends doing is not a section.** Every move in the edition lands on the project's Strategic Tickets board — as a new ticket, most important first, or on the ticket already there for that work (`tickets.alreadyOnBoard`). New tickets land in the `triage` column, and the team moves them from there; each carries the dimension and the estimate of the work where the edition gave them. Read the ones it opened with `GET /v1/projects/{projectId}/tickets?origin=briefing&briefingRunId={runId}`, taking `{runId}` from `meta.runId`. They are the team's from that moment: moving, editing or dismissing one changes the board, not the edition.\n\n**Returned with any finished edition, regardless of `sections`:** `coverage` (methodology and data-quality caveats — read these before quoting any figure), `contains` (the list of sections this edition actually holds, so you can pick what to fetch next instead of requesting slots blind), and `tickets` (how many tickets this edition opened on the board and how many of them sit in each column, counted as you read). All three 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; on a ticket this edition opened, `impact` is 1–4 (higher is more critical) and `briefing.estimatedMinutes` is the edition's own estimate of the work, `null` where it gave none — report that as unsized rather than as quick. A project's next briefing is scheduled roughly 30 days after its last run.","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- 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","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- 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","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/projects/{projectId}/tickets/labels":{"get":{"description":"List the project's label list — the only labels a ticket may carry, in the order the project picks them. A label is a name and a colour; a ticket names labels by ID. The list is empty until the project defines a label, in the app or through `POST /v1/projects/{projectId}/tickets/labels`.","operationId":"PublicTicketsController_listLabels_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ListResponseOfTicketLabelResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TicketLabelResponse"}}},"required":["items"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List the project's ticket labels","tags":["Strategic Tickets"]},"post":{"description":"Add a label to the project's list. Names are unique within the project and compared without regard to case, so `Pricing` and `pricing` cannot both exist. Answers with the whole list. Requires a `read_write` key.","operationId":"PublicTicketsController_createLabel_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTicketLabelRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ListResponseOfTicketLabelResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TicketLabelResponse"}}},"required":["items"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Create a ticket label","tags":["Strategic Tickets"]}},"/v1/projects/{projectId}/tickets/labels/{labelId}":{"patch":{"description":"Rename a label, recolour it, or both. It is one write on the list and touches no ticket, because a ticket holds the label's ID. Answers with the whole list. Requires a `read_write` key.","operationId":"PublicTicketsController_updateLabel_v1","parameters":[{"name":"labelId","required":true,"in":"path","description":"Label ID, as the label list returns it","schema":{"example":"3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTicketLabelRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ListResponseOfTicketLabelResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TicketLabelResponse"}}},"required":["items"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"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":"Rename or recolour a ticket label","tags":["Strategic Tickets"]},"delete":{"description":"Remove a label from the project's list and from every ticket carrying it. Answers with the whole list. Requires a `read_write` key.","operationId":"PublicTicketsController_deleteLabel_v1","parameters":[{"name":"labelId","required":true,"in":"path","description":"Label ID, as the label list returns it","schema":{"example":"3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ListResponseOfTicketLabelResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TicketLabelResponse"}}},"required":["items"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"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":"Delete a ticket label","tags":["Strategic Tickets"]}},"/v1/projects/{projectId}/tickets/assignees":{"get":{"description":"List the people a ticket in this project can be assigned to — everyone who is currently a member of the organization, each as the same `userId` and `fullName` a ticket already carries for its author and its assignee. This is where `assigneeUserId` comes from: a create or an update that names anyone else is refused. Somebody invited but not yet joined is not here, because a ticket cannot be assigned to them. Nothing about a person beyond their name and id is returned, on this endpoint or any other.","operationId":"PublicTicketsController_listAssignees_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ListResponseOfTicketPersonResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TicketPersonResponse"}}},"required":["items"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List the people a ticket can be assigned to","tags":["Strategic Tickets"]}},"/v1/projects/{projectId}/tickets":{"get":{"description":"List the project's Strategic Tickets, a page at a time — what the team is deciding on and working on, whether a person opened a ticket, your own automation did, or a Strategic Briefing did. By default the list is the board flattened: the columns in board order, and inside each column the order the team keeps — in `triage` that starts as the order an edition delivered its recommendations, most important first, newest edition on top, until somebody moves them. A ticket carries no order number of its own on this API; its place is where it sits in the list. Each row is a summary: every field except the Markdown `description`, which `include=description` adds. `pagination.total` counts every ticket matching every filter across all pages; `pagination.hasMore` says a next page exists — ask for `page+1`. `byStatus` counts the matches per column whatever `status` you passed, narrowed by every other filter, so `limit=1` with no other filter is the board's census. One call answers each of these. What to start: `status=triage,todo&sort=priority`. Quick wins among a briefing's recommendations: add `impactMin=3&maxMinutes=120` — that leaves out every ticket without an edition's estimate; for those, `impactMin=3&effort=low`. Overdue: `status=triage,todo,in_progress&dueBefore={today}&sort=due`. Due this week: `status=triage,todo,in_progress&dueFrom={today}&dueBefore={the day after the week ends}`. Changed since yesterday: `activeSince={the start of the customer's yesterday, as ISO 8601 with its offset — a bare day is midnight UTC}&sort=activity`; on a changed ticket, a `statusChangedAt` later than that means it changed column, and a `lastActivityAt` later than its `updatedAt` means a thread entry was written or edited after the ticket's own last change. Due dates are calendar days with no time zone, so pass the customer's own today. `origin=briefing&briefingRunId={runId}` is one edition's tickets, with `{runId}` taken from `GET /v1/projects/{projectId}/strategic-briefing/history`; they are returned whole whatever `closed` says, so the list matches the count the briefing states. To read neighbours for a move, use `sort=board` (the default) and the destination column alone: any other read can hide tickets that sit between two rows.","operationId":"PublicTicketsController_listTickets_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":"How many tickets per page, 1 to 100; 50 when omitted. Pass `limit=1` when you only need `pagination.total` and `byStatus`.","schema":{"minimum":1,"maximum":100,"default":50,"example":50,"type":"number"}},{"name":"status","required":false,"in":"query","description":"Return only the tickets in these columns — one, or several separated by commas (`status=todo,in_progress`). Omit it for every column. `byStatus` ignores this filter, so its counts still cover every column, narrowed by every other filter you passed.","schema":{"example":"todo","type":"array","items":{"type":"string","enum":["triage","todo","in_progress","done","dismissed"]}}},{"name":"sort","required":false,"in":"query","description":"The order of the list. `board` (the default) is the board flattened: the columns in board order, each in the order the team keeps — and the only order to read neighbours for a move from. `priority` puts the highest impact first; among the same impact, the least effort first, and among the same effort the fewest minutes of the edition's estimate; then the earliest due date, then board order. A missing value sorts last at each step. `due` puts the earliest due date first, undated last. `activity` puts the most recently touched first (`lastActivityAt`). Any order but `board` mixes the columns; each ticket carries its `status`.","schema":{"default":"board","example":"priority","type":"string","enum":["board","priority","due","activity"]}},{"name":"closed","required":false,"in":"query","description":"How much of `done` and `dismissed` to return. `all` (the default) returns them whole; `recent` returns only the tickets that reached those columns within the last 30 days. The other columns are always whole, whichever you pass, and a read narrowed to one edition is whole whatever you pass.","schema":{"default":"all","example":"all","type":"string","enum":["recent","all"]}},{"name":"origin","required":false,"in":"query","description":"Who opened the ticket, never which dimension it is about (that is `dimension`): `user` — a person in the app; `api` — an API key, which is what everything you write here carries; `briefing` — a Strategic Briefing; `ai_sources` — reserved for a future AI Sources writer, which opens none today, so that value matches none.","schema":{"example":"briefing","type":"string","enum":["user","api","briefing","ai_sources"]}},{"name":"dimension","required":false,"in":"query","description":"Return only the tickets a Strategic Briefing opened for one part of its analysis, or `none` for the tickets no part claims — everything a person or an API key opened, and a briefing's ticket whose edition named none. `agent-readiness` is the key for Agent Adoption — how well a site is set up for AI agents to discover, access and read it. The key predates the name and does not change.","schema":{"example":"pricing","type":"string","enum":["ai-visibility","ai-sources","positioning","pricing","content","tech-trust","agent-readiness","ai-ecosystem","customer-voice","funding-capital","hiring-gtm","landscape","product-launches","reliability-status","none"]}},{"name":"briefingRunId","required":false,"in":"query","description":"Return only the tickets one Strategic Briefing edition opened. Take the id from `GET /v1/projects/{projectId}/strategic-briefing/history`, or from `meta.runId` on a briefing read. Nothing a person or an API key opens carries an edition, so this narrows the list to that edition's tickets whether or not you also pass `origin`. An edition's tickets are returned whole, the finished and dismissed ones included however long ago they were closed — `closed` has no effect on this read — so the list matches the count the briefing states for that edition.","schema":{"example":"6a3950c06bda450a1372048e","type":"string"}},{"name":"assignee","required":false,"in":"query","description":"Return only the tickets one person owns — their user ID, from `GET /v1/projects/{projectId}/tickets/assignees`. Pass `none` for the tickets nobody owns. A ticket whose owner has left the organization reads as unassigned everywhere, but is still found here by their ID.","schema":{"example":"507f1f77bcf86cd799439031","type":"string"}},{"name":"labelId","required":false,"in":"query","description":"Return only the tickets carrying one label — its ID, from `GET /v1/projects/{projectId}/tickets/labels`. A project's list is empty until the project defines a label, in the app or through `POST /v1/projects/{projectId}/tickets/labels`.","schema":{"example":"3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2","type":"string"}},{"name":"number","required":false,"in":"query","description":"Return the one ticket carrying this number on the board — what a person means by `#14`. It is a name and not an address: this narrows the list, and every endpoint that acts on a ticket still takes its `id`.","schema":{"minimum":1,"example":14,"type":"number"}},{"name":"q","required":false,"in":"query","description":"Return only the tickets whose title or description contains this text, compared without regard to case. It is matched literally — punctuation is text, not a pattern — and threads are not searched. A match in a description is weaker evidence than one in the title: a briefing's description quotes its grounding, so read the ticket before calling two tickets the same work.","schema":{"maxLength":200,"example":"pricing page","type":"string"}},{"name":"impactMin","required":false,"in":"query","description":"Return only the tickets whose impact is at least this, 1 to 4 (1 Minor · 2 Moderate · 3 Significant · 4 Critical; 4 matters most). Tickets nobody sized are left out. A Strategic Briefing sets impact and effort on the tickets it opens, so a value there is the edition's estimate until a person changes it.","schema":{"minimum":1,"maximum":4,"example":3,"type":"number"}},{"name":"effort","required":false,"in":"query","description":"Return only the tickets sized as one of these — one, or several separated by commas (`effort=low,medium`). Tickets nobody sized are left out. This is the ticket's size word, not its minutes: a briefing's `briefing.estimatedMinutes` is the edition's own time estimate, neither is derived from the other, and a `low` ticket can be an afternoon — when a person asks for something quick, use `maxMinutes`.","schema":{"example":"low","type":"array","items":{"type":"string","enum":["low","medium","high"]}}},{"name":"maxMinutes","required":false,"in":"query","description":"Return only the tickets a Strategic Briefing opened whose edition estimated at most this many minutes of work. A ticket without an edition's estimate — everything a person or an API key opened, and a briefing's ticket the edition did not size — is left out; use `effort` for those.","schema":{"minimum":1,"example":120,"type":"number"}},{"name":"dueFrom","required":false,"in":"query","description":"Return only the tickets due on or after this calendar day, `YYYY-MM-DD`. Tickets with no due date are left out. A due date has no time zone, so pass the customer's own day.","schema":{"example":"2026-10-01","type":"string"}},{"name":"dueBefore","required":false,"in":"query","description":"Return only the tickets due strictly before this calendar day, `YYYY-MM-DD`. `dueBefore=` with the open columns (`status=triage,todo,in_progress`) is everything overdue; with `dueFrom` it is a range. Tickets with no due date are left out. A due date has no time zone, so pass the customer's own day.","schema":{"example":"2026-10-08","type":"string"}},{"name":"activeSince","required":false,"in":"query","description":"Return only the tickets something happened to at or after this moment — an edit, a move, or a new thread entry (`lastActivityAt`). ISO 8601 with its offset; a time without one is read as UTC, and `YYYY-MM-DD` is the start of that day in UTC. A deleted ticket is gone and does not appear.","schema":{"example":"2026-09-21","type":"string"}},{"name":"include","required":false,"in":"query","description":"What each ticket in the list carries. By default a list is an INDEX: every field except the ticket's own Markdown `description`, which is then not on the row at all. Pass `include=description` for the bodies — one ticket's description runs to thousands of characters, so a page fetched with them is large enough to be worth asking for on purpose. `GET …/tickets/{ticketId}` always returns the description whatever you pass here.","schema":{"example":"description","type":"array","items":{"type":"string","enum":["description"]}}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"PaginatedResponseOfTicketListItemResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TicketListItemResponse"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"},"byStatus":{"type":"object","description":"How many tickets match every filter of this call EXCEPT `status`, per column — every column present, the empty ones as 0. One call with `limit=1` and no other filter says how a whole board is spread; with filters, how the matches are.","properties":{"triage":{"type":"number"},"todo":{"type":"number"},"in_progress":{"type":"number"},"done":{"type":"number"},"dismissed":{"type":"number"}},"required":["triage","todo","in_progress","done","dismissed"],"example":{"triage":0,"todo":1,"in_progress":2,"done":3,"dismissed":4}}},"required":["items","pagination","byStatus"]}}}},"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"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List project tickets","tags":["Strategic Tickets"]},"post":{"description":"Open a ticket on the project's board. `title` and `status` are required — `status` names the column it lands in and has no default, because where a ticket belongs depends on who opened it. A new ticket lands at the top of its column. Labels are named by ID from the project's label list, so create the label there first. A board holds at most 5,000 tickets; past that a create is refused with 400 until some are deleted. The ticket is recorded as opened by an API key — `origin` is `api` and `author` is null — which is how the board tells your automation's tickets from a person's. Requires a `read_write` key.","operationId":"PublicTicketsController_createTicket_v1","parameters":[{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTicketRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTicketResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TicketResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Create a ticket","tags":["Strategic Tickets"]}},"/v1/projects/{projectId}/tickets/{ticketId}":{"get":{"description":"Get one ticket: its description, its labels resolved to name and colour, its assignee, its dates, and how many entries its thread holds. On a ticket a Strategic Briefing opened, `briefing` names the edition it came from; it is `null` on every other ticket. An ID that belongs to another project answers 404 exactly as an ID that exists nowhere does.","operationId":"PublicTicketsController_getTicket_v1","parameters":[{"name":"ticketId","required":true,"in":"path","description":"Ticket 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":"ItemResponseOfTicketResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TicketResponse"}},"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"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"Get a ticket","tags":["Strategic Tickets"]},"patch":{"description":"Change a ticket's content and planning fields. Omit a field to leave it as it is, send a value to replace it, send `null` to clear it. The description and the labels are cleared by their own empty value instead — `\"\"` and `[]` — because for those an empty value is a real one. A column, and a place inside one, are a move and are not accepted here, so a ticket can never change column as a side effect of an edit. Requires a `read_write` key.","operationId":"PublicTicketsController_updateTicket_v1","parameters":[{"name":"ticketId","required":true,"in":"path","description":"Ticket ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTicketRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTicketResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TicketResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"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":"Update a ticket","tags":["Strategic Tickets"]},"delete":{"description":"Delete a ticket and its thread. A ticket a Strategic Briefing opened cannot be deleted — move it to `dismissed` instead, so that what opened it does not open it again. Read `deletable` on the ticket first; deleting one that is not is refused with 400. Requires a `read_write` key.","operationId":"PublicTicketsController_deleteTicket_v1","parameters":[{"name":"ticketId","required":true,"in":"path","description":"Ticket 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":"ItemResponseOfTicketDeletedResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TicketDeletedResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"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":"Delete a ticket","tags":["Strategic Tickets"]}},"/v1/projects/{projectId}/tickets/{ticketId}/move":{"patch":{"description":"Move a ticket to another column, or reorder it inside the one it is in. Name the destination column, then where it goes: `position=top` or `position=bottom`, or the two tickets it will sit between — `beforeId` is the one directly ABOVE it, `afterId` the one directly BELOW; name either or both. A request naming a position and a neighbour is refused. Omitting everything puts it at the bottom — the opposite of a new ticket, which lands at the top. There is no index to compute — you name neighbours you can see and the server works out the order, so two clients moving tickets at the same time converge on one order instead of overwriting each other. A neighbour that has been deleted, or now sits in another column, is not used; if you named both and the one below now sits above the one above, the one above is kept; if neither can be used, the ticket goes to the bottom. A move never fails because your view of the board was a moment old. The answer says where it landed: `placement.above` and `placement.below` are its neighbours now, and `placement.ignored` names each neighbour you named that was not used and why — `other_column` most often means you read it off another column's list. An empty `ignored` means every neighbour you named was used, not that nothing sits between them: compare `above` and `below` with what you named, and if they differ and the exact place matters, re-read that column in board order and move again. Reordering inside a column leaves `statusChangedAt` alone. Requires a `read_write` key.","operationId":"PublicTicketsController_moveTicket_v1","parameters":[{"name":"ticketId","required":true,"in":"path","description":"Ticket ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MoveTicketRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TicketMovedResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"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":"Move a ticket","tags":["Strategic Tickets"]}},"/v1/projects/{projectId}/tickets/{ticketId}/comments":{"get":{"description":"List a ticket's thread, oldest first and whole — nothing pages it, so nothing is counted twice. Each entry says what wrote it: a person working in the app, or an API key.","operationId":"PublicTicketsController_listComments_v1","parameters":[{"name":"ticketId","required":true,"in":"path","description":"Ticket 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":"ListResponseOfTicketCommentResponse","type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TicketCommentResponse"}}},"required":["items"]}}}},"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"}}}},"429":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiRateLimitErrorEnvelope"}}}}},"security":[{"CL-API-Key":[]}],"summary":"List a ticket's comments","tags":["Strategic Tickets"]},"post":{"description":"Add an entry to a ticket's thread, written in Markdown. The entry is recorded as written by an API key. A thread holds at most 500 entries. Requires a `read_write` key.","operationId":"PublicTicketsController_createComment_v1","parameters":[{"name":"ticketId","required":true,"in":"path","description":"Ticket ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTicketCommentRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTicketCommentResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TicketCommentResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"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":"Add a ticket comment","tags":["Strategic Tickets"]}},"/v1/projects/{projectId}/tickets/{ticketId}/comments/{commentId}":{"patch":{"description":"Rewrite an entry this API wrote. An entry a person wrote in the app is theirs to edit there, and rewriting it here answers 403 `forbidden`. The text is the only part of an entry anyone may change. Requires a `read_write` key.","operationId":"PublicTicketsController_updateComment_v1","parameters":[{"name":"ticketId","required":true,"in":"path","description":"Ticket ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}},{"name":"commentId","required":true,"in":"path","description":"Comment ID","schema":{"example":"507f1f77bcf86cd799439021","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTicketCommentRequestDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTicketCommentResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TicketCommentResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"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":"Edit a ticket comment","tags":["Strategic Tickets"]},"delete":{"description":"Delete an entry from a ticket's thread. A key reaches any entry on the ticket, whoever wrote it: a key is issued by an owner or an admin and acts with that authority. Requires a `read_write` key.","operationId":"PublicTicketsController_deleteComment_v1","parameters":[{"name":"ticketId","required":true,"in":"path","description":"Ticket ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}},{"name":"commentId","required":true,"in":"path","description":"Comment ID","schema":{"example":"507f1f77bcf86cd799439021","type":"string"}},{"name":"projectId","required":true,"in":"path","description":"Project ID","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"title":"ItemResponseOfTicketDeletedResponse","type":"object","properties":{"item":{"$ref":"#/components/schemas/TicketDeletedResponse"}},"required":["item"]}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiValidationErrorEnvelope"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiUnauthorizedErrorEnvelope"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiForbiddenErrorEnvelope"}}}},"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":"Delete a ticket comment","tags":["Strategic Tickets"]}},"/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"}}}},"402":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiPaymentRequiredErrorEnvelope"}}}},"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":"