Skip to main content
POST
Get Brand Intelligence Content Group Insights Route

Authorizations

Authorization
string
header
required

Bearer authentication header of the form Bearer <token>, where <token> is your auth token.

Path Parameters

brand_id
string<uuid>
required

Body

application/json

Content cohort plus the vocabulary used by the pillar roll-up.

auto derives a deterministic starting vocabulary from the stored cohort evidence and ignores rollup_pillars. frozen uses exactly the supplied vocabulary. Carrying that vocabulary in the request keeps a saved comparison stable when later acquisitions discover new terms.

domains
string[]
Maximum array length: 60
group_label
string | null
Maximum string length: 120
rollup_mode
enum<string>
default:auto
Available options:
auto,
frozen
rollup_pillars
string[]
Maximum array length: 25
rollup_limit
integer | null
Required range: 1 <= x <= 25

Response

Successful Response

brand_id
string<uuid>
required
generated_at
string<date-time>
required
group_key
string
required
module
string
default:content
Allowed value: "content"
group_label
string | null
brand_domain
string | null
domains
string[]
cohort_domains
string[]
coverage
ModuleCoverageStrip · object

N-of-M acquisition coverage for one module's cohort (the ★S0/★C0 strip).

measured counts cohort domains that produced a stored metric row. missing are targets that never did — their fetch or persist failed, or they have not been acquired yet. For Content, fetch_failed is a typed subset of missing, not an additional shortfall, so clients must de-duplicate rather than sum those lengths. A competitor with a failed acquisition must render as a failure chip, never as a measured zero.

position
ModuleDimensionRank · object[]
source_health
ContentSourceHealthPanel · object

★C0 — per-competitor source health rolled up for the cohort.

Failed sources used to be DELETED from the UI, which made a brand whose blog was blocked look identical to one with no blog.

cadence
ContentCadencePanel · object

★C1 — publishing velocity vs measured competitors.

measured_count and cohort_median_posts exclude the user's brand; brand_rank ranks the brand across the combined measured population.

brand_rank is a COMPETITION rank ("1224"): domains that published the same number of posts share a place, and only a domain that published MORE pushes the brand down one. So rank 1 means "nobody published more" — NOT "published more than everyone", which a tie also satisfies. brand_tied_count includes the brand itself and makes that shared place explicit to every renderer.

rows_with_suppressed_cadence counts over the FULL cohort, not the truncated rows list, and is None rather than 0 when nothing was suppressed: module_group_commentary._flatten_refs skips None but flattens 0 into evidence the narrator may quote, so a 0 would hand the model a "0 rows suppressed" fact about every cohort.

format_mix
ContentFormatMixPanel · object

★C2 — article/video/gated split across the cohort vs the brand.

pillars
ContentPillarPanel · object

★C3 — shared topics (table stakes) vs uncontested ground.

pillar_rollup
ContentPillarRollupPanel · object

Request-scoped pillar vocabulary rolled across stored item samples.

Unlike the sibling full-cohort panels, this comparison deliberately caps competitors before vocabulary and aggregate computation. The explicit total/included/truncated accounting prevents that bounded denominator from masquerading as complete cohort coverage.

headline_patterns
ContentHeadlinePatternsPanel · object

★C10 — the shape of the cohort's headlines, from stored titles only.

Fully deterministic: every field is arithmetic over CompetitorContentMetric.latest_items[*].title, which is already persisted. No provider call, no model call, no acquisition — see content_headline_patterns.py for why an LLM was declined.

Four states, deliberately distinguishable:

  • measured=False + not_measured_reason="no_content_measured";
  • measured=False + "no_titles_stored" (read, but zero titles);
  • measured=False + "insufficient_titles" (below the floor — the counts are still carried so the reader sees "12 of 20 needed");
  • measured=True with a 0.0 share — a measured zero.

A measured panel carries a row for EVERY pattern, including unused ones. Filtering the zeros would make a real "nobody does this" indistinguishable from "we did not check".

lead_magnets
ContentLeadMagnetPanel · object

★C4 — the cohort's tried-and-true capture patterns.

demand_join
ContentDemandJoinPanel · object

★C8 — the content ↔ demand join, benchmarked on the SAME cohort.

Three buckets: pillars the brand publishes that the cohort's SEO keyword universe confirms have demand; demand the cohort captures that the brand publishes nothing for; and pillars the brand publishes with no demand evidence anywhere in the cohort's keyword universe.

measured is False unless BOTH sides were measured for this cohort — a missing SEO capture must never render as "no demand".

cohort_demand is the brand-side-WITHHELD half of the join: the demand the cohort captures, with brand_publishes=None on every row. It is populated ONLY when the SEO side is measured and the brand side is not, which is the default new-cohort state — a fully-measured cohort should not render as a blank panel just because the brand has not been read yet. When measured is True this list is empty and the three comparison buckets carry the answer, so no term is ever rendered twice.

ads_join
ContentAdsJoinPanel · object

★C8b — the content ↔ paid-ads join, benchmarked on the SAME cohort.

Three buckets: pillars the brand publishes that the cohort's Google Ads keyword universe confirms someone is backing with paid spend; paid keywords the cohort bids on that the brand publishes no content for; and pillars the brand publishes with no paid-keyword evidence anywhere in the cohort's Ads universe.

measured is False unless BOTH sides were measured for this cohort — a missing Ads capture must never render as "no paid demand".

cohort_ad_spend is the brand-side-WITHHELD half of the join: the paid keywords the cohort backs, with brand_publishes=None on every row. It is populated ONLY when the ads side is measured and the brand side is not, which is the default new-cohort state — a fully-measured cohort should not render as a blank panel just because the brand has not been read yet. When measured is True this list is empty and the three comparison buckets carry the answer, so no term is ever rendered twice.

ai_visibility
ContentAiVisibilityPanel · object | null

Weekly observed AI-answer mentions and citations for this cohort.

This is a read-only projection of the already-bounded weekly SEO capture; opening Content Insights never triggers a provider request.

page_performance
ContentPagePerformancePanel · object | null

Content's first MEASURED performance signal (design 9.2).

Every other Content panel counts what a publisher SHIPPED. This one reports what individual pieces EARNED, joined by URL onto the same bounded item sample the rest of the module already renders.

Three states, and collapsing any two is the defect this panel exists to avoid:

  • measured=False - no publisher in this cohort has a stored page-index capture. Nothing can be said. This is the state a cohort sits in while the acquisition flag is off, and it must never render as zeros.
  • measured=True with rows == [] - a page index WAS read for at least one publisher and none of the sampled URLs appear in it. A real finding, sized by pages_sampled, not a failure.
  • measured=True with rows - render them, remembering that a blank cell inside a row is still "not measured".
ai_citations
ContentAiCitationsPanel · object | null

Which of a publisher's PAGES an AI assistant cites (design 9.3).

Deliberately its own panel rather than three more columns on ContentPagePerformancePanel, and deliberately NOT merged into the module's brand-level AI visibility panel. The three are different claims:

  • brand-level AI visibility (C5) - is this BRAND named when an assistant is asked about the category. Re-read of the SEO module's llm_scraper capture.
  • per-page citations (this) - is this URL cited. DataForSEO LLM Mentions, keyed per page, different denominator.
  • per-page organic/link performance - did this URL earn search traffic and links. No AI surface involved at all.

A reader must be able to tell which is which, so each gets its own heading, its own basis notice and its own measured/unmeasured verdict. platform is required reading alongside any count: "cited by ChatGPT" and "cited in Google's AI Overviews" are not interchangeable.

tech_stack
ContentTechStackPanel · object | null

What the tracked publishers BUILD with, versus what the brand builds with.

Every other Content panel measures output. This one measures the content stack behind the output — CMS, static-site generator, analytics, tag manager, personalisation, email platform — so a reader can see that four of the publishers they track run a headless CMS and they do not.

The claim is bounded on three axes, and each one is on the payload.

  1. Evidence direction. Only detections are counted. detected_count is out of competitors_scanned, never out of competitors_in_cohort: a domain nobody scanned is not a domain that lacks the technology.
  2. Sample size. competitors_scanned / competitors_in_cohort sizes the comparison, and brand_scanned says whether the "versus you" half is expressible at all. When it is False the differential is withheld — enforced below, not merely documented.
  3. Age. Detections carry the provider's own last_visited, which can be materially older than the refresh that fetched it. oldest_detected_at bounds how current the comparison is and stale_domain_count says how much of it is past stale_after_days.

Three states, as everywhere else in this module: measured=False is "no cohort domain has a stored scan" and must never render as "no technologies"; measured=True with rows == [] is "we scanned and detected nothing we report", which is genuinely rare and still not proof of an empty stack.

publisher_evidence
ContentPublisherEvidenceRow · object[]
Maximum array length: 25
comparison_basis
ContentComparisonBasisPanel · object

Whether a period-over-period comparison is possible for this cohort AT ALL.

Three states. Collapsing any two of them is the defect this panel exists to prevent, because each one licenses a different sentence on screen:

  • measured=False — no tracked publisher has a stored capture, so we cannot say anything about history. Not a first generation; an unknown.
  • measured=True and has_prior_snapshot=False — captures exist but none has a predecessor on its own measurement series. This is the FIRST-EVER generation for the group: there is nothing to compare against, which is emphatically not "we compared and nothing changed".
  • has_prior_snapshot=True — a real comparison basis exists, so an empty change feed is a measured absence of qualifying movement.

The series grain matches content_insight_events's own baseline test (detect_content_publisher_delta returns None when the previous snapshot on the same domain/source/source_url series is missing): a publisher needs two captures on ONE series before any delta can exist. Normalization here is deliberately no tighter than the emitter's so that a mismatch can only over-count history — which suppresses the first-generation copy and falls back to the existing wording, never the other way round.

findings
ModuleRuleFinding · object[]