Overture is the marketing site's engagement overlay system. A configurable card slides
in at the bottom-right of matters.ai pages after a delay (default 7 seconds), shows
one of six premade archetypes, and captures visitor intent — newsletter subscriptions,
webinar registrations, lead details, whitepaper downloads — or simply presents a message.
Everything about it (targeting, copy, timing, frequency, scheduling, appearance) is
configured only in the workspace at /overture.
Design spec: docs/superpowers/specs/2026-07-18-overture-popup-engine-design.md.
| Kind | Form fields | Submits to | Server effect |
|---|---|---|---|
subscribe | email (+ optional name) | POST /api/overture/convert | upsertConfirmedSubscription into universal_subscriptions (source overture), optional MailerLite group + Salesforce push |
lead | name, email, company (+ optional phone) | POST /api/overture/convert | Insert into mattersai-leads / overture_leads (visible in Audience → Leads), optional Salesforce/MailerLite |
webinar | name, email, company | existing POST /api/webinars/register | The full webinar pipeline is reused untouched: dedupe, confirmation email, fully-booked fallback, Salesforce + MailerLite |
whitepaper | name, email, country (+ company) | existing POST /api/whitepapers/submit | Existing pipeline: lead sheet + Mongo + Slack, then the card blob-downloads the returned file |
testimonial | none | — | Social-proof quote (picked from lib/testimonials.ts or custom) + optional CTA link |
announcement | none | — | Headline + body + CTA link (webinar promo, case study, event) |
Copy is fully editable per overture: title, body, ctaLabel, dismissLabel,
successTitle, successBody.
One MongoDB collection: db mattersai, collection overtures. The canonical shape is
Overture in lib/schemas/overture.ts (Zod is the single source of truth):
name, slug (unique, kebab-case), status (draft | active | paused | archived), priority (0–1000)content — discriminated union on kind (the archetype table above)targeting.include / targeting.exclude — path patterns (grammar below)behavior — delaySeconds (default 7), frequency, cooldownDays (default 7), startAt/endAt schedule windowappearance.tone — paper (white/blue), ink (black/white), blue (brand blue)appearance.shape — soft (rounded 20px card, 10px controls) or edge (sharp 4px card, 2px controls) — 6 combined looksstats — impressions, dismissals, conversions (server-side $inc counters)created_at/by, updated_at/by (Clerk user ids)Implemented in lib/overture/match.ts (pure, jest-tested in tests/overture/match.test.ts):
| Pattern | Matches |
|---|---|
/ | homepage only |
/pricing | that exact path (case-insensitive, trailing slash/query/hash normalized) |
/platform/* | exactly one extra segment (/platform/agents, not /platform/agents/x) |
/webinars/** | any depth including /webinars itself |
** | every page |
Rules: at least one include must match AND no exclude may match — exclusions always
win. When several active overtures match a page, the highest priority wins; ties go to
the most recently updated. At most one popup ever shows per page view.
Hard-excluded route prefixes (never show, regardless of config): /admin, /wp-admin,
/tools, /auth, /workspace, /overture, /confirm, /c/.
Implemented in lib/overture/frequency.ts (jest-tested), state stored client-side in
localStorage (matters_overture_state, versioned) + sessionStorage:
| Frequency | Behavior |
|---|---|
always | every eligible page view |
once_per_session | once per browser session |
once_per_visitor | once ever per device |
until_dismissed | shows until dismissed; re-eligible cooldownDays after dismissal |
until_converted (default) | dismissals obey the cooldown; a conversion silences it permanently |
Corrupt/blocked storage always degrades to "show". The popup also defers while the
cookie-consent banner is visible (they never stack) and respects prefers-reduced-motion.
Marketing site (matters.ai, root app)
├── components/overture/OvertureHost.tsx — mounted in app/layout.tsx (inside ConsentProvider)
│ fetches /api/overture/active (TanStack Query, staleTime 10 min)
│ re-matches on every client navigation, runs the delay timer,
│ frequency-caps, emits GA4/GTM + server counters
├── components/overture/OvertureCard.tsx — presentational card, 3 brand tones, framer-motion
├── components/overture/forms/* — per-kind mini-forms (react-hook-form + zod + reCAPTCHA)
└── app/overture/preview — noindex postMessage preview surface for the editor
Public APIs (root app)
├── GET /api/overture/active — active set, cachedFetch tag "overtures", revalidate 300 s
├── POST /api/overture/convert — subscribe/lead conversions (origin check → BotID → reCAPTCHA → zod)
└── POST /api/overture/track — impression/dismiss/convert counters (sendBeacon-friendly)
Workspace (workspace.matters.ai)
├── /overture — list: status/kind, targeting summary, stats, CVR, pause/activate
├── /overture/[id] ("new" = create) — editor + live preview iframe
└── /api/workspace/overture[/*] — CRUD + duplicate + picker options, org:marketing:* gated
The service layer (lib/services/overture.service.ts) is shared by both apps through the
@/lib alias. Fan-out toggles (MailerLite/Salesforce) are resolved server-side from the
stored overture on every conversion — the client cannot force pushes the admin didn't
enable.
GET /api/overture/active is cached under the overtures tag with a 300 s revalidate
(plus CDN s-maxage=300). Admin writes call revalidateTag("overtures", "max") locally
and POST a { tags: ["overtures"] } body to the marketing deployment's
/api/revalidate webhook (same WORDPRESS_WEBHOOK_SECRET, strict tag allowlist) — because
the workspace and marketing site are separate deployments, the local revalidate alone
cannot bust the marketing cache. Worst case propagation without the webhook: 5 minutes.
A dedicated marketing scope (added to lib/auth/rbac-shared.ts):
| Permission | Grants |
|---|---|
org:marketing:view | see the module, list, options (also: content_editor, publisher) |
org:marketing:edit | create/update/duplicate, and the editor page |
org:marketing:delete | delete (edit also suffices — mirrors other modules) |
Full grants: super_admin, admin, marketing_manager. content_manager gets view+edit.
Enforcement is two-layered: requirePermission in the layout/pages and per-verb
checkRole in every API handler. The nav entry (badge Marketing) lives in
lib/workspace-nav.ts.
The editor iframes the marketing site's /overture/preview page and postMessages the
draft (debounced 300 ms, message type overture:preview). The preview page allowlists
message origins (same-origin, NEXT_PUBLIC_WORKSPACE_URL, localhost) and re-validates the
draft's content with Zod before rendering the real OvertureCard — so what you see is the
production rendering, real fonts and all, not a workspace-theme approximation.
Three server counters per overture (impressions, dismissals, conversions — CVR shown
in the list) plus GTM dataLayer events for GA4/LinkedIn/Meta tags:
overture_impression, overture_dismiss, overture_conversion, each with
overture_slug, overture_kind, page_path. Webinar/whitepaper conversions (which submit
to their own endpoints) are counted client-side via the track endpoint; subscribe/lead
conversions are counted server-side in /api/overture/convert.
After a conversion the card shows its success copy, then self-closes after 6 seconds; a post-conversion close (auto or the X) is never counted as a dismissal. The editor preview keeps the success state pinned for inspection, and re-posts of an unchanged draft never reset the preview card.
The editor's live preview iframes the marketing site — in local dev the
marketing app must be running on port 4000, and both dev servers must be
restarted after next.config.ts changes (headers are loaded at boot).
The module ships dark: nothing renders on the site until an overture is created and
set active in the workspace.
/api/overture/active never 500s the shell — any failure returns an empty list and the
host renders nothing.
Deleting an overture removes it from the site within the propagation window; its stats go with it (duplicate first if you want to keep history).
overture_leads is registered in lib/services/sales/lead-collections.ts, so lead-kind
submissions appear in the Audience leads viewer and the sales export (RBAC-gated).
org:marketing:* scope extends.