Skip to content

Audience Segments + Story Variants

Personalize what a Story renders based on visitor signals (country, language, referrer, custom cookies, etc.) without forking the URL.

Conceptual model

text
┌─ Space ────────────────────────────────────────────────────┐
│  ┌─ AudienceSegment "EU-visitors" ──────────────────────┐  │
│  │  rules: [country in {DE, AT, CH, FR}, ...]           │  │
│  │  priority: 10                                        │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌─ AudienceSegment "free-trial-users" ─────────────────┐  │
│  │  rules: [cookie peacock_plan == "free"]              │  │
│  │  priority: 20                                        │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌─ Story "/pricing" ───────────────────────────────────┐  │
│  │  default content: ...                                │  │
│  │  variants:                                           │  │
│  │    └─ Variant for segment "EU-visitors" (EUR prices) │  │
│  │    └─ Variant for segment "free-trial-users" (CTA)   │  │
│  └──────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────┘

The CDN matches segments left-to-right by priority, picks the highest-priority match, then serves the variant content (or the Story default if no variant exists for that segment).

Segments

http
GET    /v1/spaces/{slug}/audience-segments
POST   /v1/spaces/{slug}/audience-segments
PATCH  /v1/spaces/{slug}/audience-segments/{id}
DELETE /v1/spaces/{slug}/audience-segments/{id}

POST body:

json
{
  "key": "eu-visitors",
  "label": "EU Visitors",
  "priority": 10,
  "rules": [
    { "type": "country", "operator": "in", "value": ["DE", "AT", "CH", "FR"] }
  ]
}

Rule types: country, language, referrer, cookie, query, utm_source, utm_campaign. Operators: eq, neq, in, not_in, contains, starts_with, regex.

Country detection

country rules need a CF-IPCountry header set by Cloudflare or a trusted edge. Without a trusted-edge marker, country rules are advisory — they match against the request header but downstream code can't trust it (security audit fix, 2026-05-21).

Variants

http
GET    /v1/spaces/{slug}/stories/{uuid}/variants
POST   /v1/spaces/{slug}/stories/{uuid}/variants
PATCH  /v1/spaces/{slug}/stories/{uuid}/variants/{id}
DELETE /v1/spaces/{slug}/stories/{uuid}/variants/{id}

POST body:

json
{
  "audience_segment_id": 4,
  "content": { "type": "doc", "blocks": [...] }
}

The content shape is identical to a normal Story version — the variant fully overrides the default.

CDN resolution

On every CDN read, the controller:

  1. Reads request signals (Accept-Language, CF-IPCountry, cookies, query).
  2. Iterates active segments in priority order.
  3. Picks the first segment whose rules ALL match.
  4. If a Variant exists for that segment on this Story, serves it.
  5. Otherwise serves the Story default.

Response carries meta.served_segment (key) so the frontend can display "We're showing you the X variant" if desired.

Edge caches must Vary: X-Peacock-Segment — the SDK auto-sets this header on subsequent requests from the resolved segment so cached variants don't shadow each other.

See also