Skip to content

Repository files navigation

Mautic c15t Consent Bundle

A Mautic plugin that turns your Mautic install into the single embeddable consent-management endpoint for every site you run: one <script> tag, served from your own Mautic instance, that shows an accessible consent banner, talks to a self-hosted c15t backend, gates Mautic's own tracking (mtc.js) on actual consent, and can conditionally load third-party scripts (Google Analytics/Tag Manager, Meta/Reddit/TikTok/X pixels, LinkedIn Insight Tag, PostHog, or any raw script you configure) only once a visitor consents to the relevant category.

Installing

  1. Copy this repo's contents into your Mautic install's docroot/plugins/C15tBundle/ (or wherever your Mautic build maps plugins/ to -- check composer.json's install-directory-name).
  2. Run php bin/console cache:clear and reload Mautic's Plugins page (or php bin/console mautic:plugins:reload if your Mautic version has it) so it picks up the new bundle.
  3. Plugins -> Consent Manager (c15t) -- toggle it published (this is the master fail-closed switch; nothing serves from /consent.js while it's off).
  4. Configuration -> Consent Manager (c15t) -- fill in:
    • Allowed domains -- one per line, every site that will embed this instance's /consent.js.
    • Test domains -- one per line, independent of Allowed domains above (a domain doesn't need to be on both lists). Sites here still get served /consent.js normally, but the consent runtime initializes in offline mode instead of hosted -- no calls to the c15t backend, consent state stays local to the browser. Useful for staging/QA sites that shouldn't write real consent records into your production c15t database.
    • c15t backend URL -- your self-hosted c15t backend's base URL, e.g. https://consent.example.com/api (whatever basePath your c15t backend was configured with -- /api is a natural choice on a dedicated subdomain that hosts nothing else).
    • Consent categories -- multi-select of which categories the banner offers (necessary is always included).
    • Disable default banner styling -- turn on if a site will supply its own CSS instead.
    • Trap focus in the consent banner/dialog -- on by default. Keeps Tab/Shift+Tab cycling within the open banner/dialog instead of escaping into the rest of the page.
    • Reload page on more restrictive consent -- off by default. When a returning visitor revokes a previously-allowed category, reloads the page instead of relying on already-loaded third-party scripts to tear themselves down cleanly in place.
    • Banner text / Manage preferences dialog text -- optional, override the default copy shown in the first-time banner and, separately, at the top of the "Manage cookie preferences" dialog.
    • One panel per packaged integration (Mautic tracking, GA4, GTM, PostHog, Meta/Reddit/TikTok pixels, LinkedIn Insight Tag, X pixel) -- each has its own enable toggle and its own parameter field(s) (e.g. Meta Pixel's Pixel ID).
    • Advanced: custom scripts (JSON) -- optional, for anything not in the packaged list. A JSON array of raw-src/raw-inline entries:
[
  { "integration": "raw-src", "id": "my-script", "src": "https://example.com/widget.js", "category": "functionality" },
  { "integration": "raw-inline", "id": "my-inline", "textContent": "console.log('consented')", "category": "marketing" }
]
  1. Embed on the target site -- see "Embedding on a site" below.

You also need a running c15t backend somewhere (backendURL above) -- this plugin is the embedding/config/gating layer, not the consent-storage backend itself. See c15t's own self-host docs.

Policy packs / consent model (which jurisdictions get a banner, and under which model) are not configured here at all -- they live entirely on the c15t backend's own c15tInstance() call (self-host policy-packs guide), not this plugin. This plugin only controls presentation (text, focus trap, reload-on-restrict) once the backend has already decided a banner should show; it doesn't decide whether one shows in the first place. An earlier version of this plugin had consent_mode/policy_packs fields here that fed a client-side option which turned out not to exist -- hosted-mode clients defer entirely to the backend's own /init response for jurisdiction/policy resolution. Confirmed live, 2026-08-18.

Embedding on a site

Add one script tag to the site, ideally in <head> so it runs as early as possible:

<script src="https://your-mautic.example.com/consent.js" defer></script>

That's the entire integration -- no other markup, JS, or build step is needed on the site itself. Once that tag is in place:

  • The banner (or, once a visitor has already decided, nothing) mounts itself automatically into a <div id="ccm-root"> it creates.
  • Every script this instance is configured to load (Mautic's own tracking, GA4/GTM, pixels, anything in "Advanced: custom scripts") only actually loads once a visitor consents to its category -- there's nothing further to gate manually on the site's side.

Two things have to line up before this works, both configured on the Mautic side (Configuration -> Consent Manager (c15t), see "Installing" above), not on the site:

  1. The site's domain must be in "Allowed domains". /consent.js resolves the requesting site from its Origin/Referer header and refuses (404) anything not on that list -- match it exactly (bare host, no scheme, e.g. www.example.com).
  2. The c15t backend's own trustedOrigins must include the site too. The allowlist above only gates who gets served the loader script; the consent runtime's own calls from the visitor's browser to your c15t backend (backendURL) are a separate cross-origin request that backend has to trust independently. See c15t's self-host docs.

Styling the banner

The default banner ships with its own CSS, themeable via custom properties -- override any of these on the site without touching "Disable default banner styling":

:root {
  --ccm-bg: #ffffff;
  --ccm-fg: #111827;
  --ccm-muted: #6b7280;
  --ccm-border: #e5e7eb;
  --ccm-primary: #111827;
  --ccm-primary-fg: #ffffff;
  --ccm-radius: 12px;
  --ccm-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
  --ccm-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
}

For anything beyond variables, turn on "Disable default banner styling" in Configuration -> Consent Manager (c15t) and style the markup directly -- the same data-ccm-* hooks the default CSS targets are always present regardless of that setting: [data-ccm-banner], [data-ccm-dialog], [data-ccm-overlay], [data-ccm-actions], [data-ccm-primary], [data-ccm-category].

Reopening the banner after a visitor has already decided

The banner only shows once, by design -- after a visitor picks "Necessary only"/"Accept all"/saves custom preferences, /consent.js mounts nothing on later page loads. To give visitors a way to change their mind later (a footer "Cookie Settings" link, for example), you need one of:

  • No JS needed -- add data-ccm-trigger to any element:

    <a href="#" data-ccm-trigger>Cookie Settings</a>
  • Programmatic -- call the same thing from your own JS:

    window.ccm.openPreferences();

Both re-open the preferences dialog (not the banner) with the visitor's existing choices pre-filled.

Resetting consent (testing)

window.ccm.resetConsents() clears a visitor's saved preferences back to the unset default, so the banner shows again on the next load -- the documented way to force a fresh banner while testing, without needing to manually clear cookies/storage (which, in hosted mode, may not even be where the "no banner" decision is coming from -- see the jurisdiction note above; a banner not showing is very often a policy-pack/region match, not saved consent state at all).

Supported packaged integrations

Key Vendor Required params
mautic-tracking This Mautic instance's own mtc.js (none -- auto-detected from Mautic's own site_url config)
google-tag Google Analytics (GA4) id
google-tag-manager Google Tag Manager id
posthog PostHog id (optional apiHost)
meta-pixel Meta Pixel pixelId
reddit-pixel Reddit Pixel pixelId
tiktok-pixel TikTok Pixel pixelId
linkedin-insights LinkedIn Insight Tag id
x-pixel X (Twitter) Pixel pixelId

Anything not on this list can still be added per-site without a plugin update, via the raw-src (a script URL) or raw-inline (literal inline JS) integration types -- see Service/IntegrationRegistry.php's own docblock.

Building

npm install
npm run build   # -> Assets/build/consent-bundle.js

Re-run and commit the result whenever anything under Assets/src/ changes.

Local validation caveat

Policy packs were originally (incorrectly) implemented client-side in this plugin, confirmed broken live, then moved to the backend, 2026-08-18. A first version passed a policyPacks array from Assets/src/index.js into getOrCreateConsentRuntime(); this compiled and shipped without error but silently did nothing -- confirmed live by a banner that never appeared, root-caused by reading @c15t/core's actual ConsentRuntimeOptions type (no policyPacks key exists there at all; hosted mode defers entirely to the backend's /init response) and then confirmed against c15t's own self-host policy-packs guide, whose real working example puts policyPacks on c15tInstance() instead. Lesson: a docs page showing policyPackPresets usage without also showing the call it feeds into isn't enough to trust an integration point against -- the client and server packages both export similarly-named things for different purposes.

Releases

Packages

Contributors

Languages