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.
- Copy this repo's contents into your Mautic install's
docroot/plugins/C15tBundle/(or wherever your Mautic build mapsplugins/to -- checkcomposer.json'sinstall-directory-name). - Run
php bin/console cache:clearand reload Mautic's Plugins page (orphp bin/console mautic:plugins:reloadif your Mautic version has it) so it picks up the new bundle. - Plugins -> Consent Manager (c15t) -- toggle it published (this is
the master fail-closed switch; nothing serves from
/consent.jswhile it's off). - 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.jsnormally, 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(whateverbasePathyour c15t backend was configured with --/apiis a natural choice on a dedicated subdomain that hosts nothing else). - Consent categories -- multi-select of which categories the
banner offers (
necessaryis 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-inlineentries:
- Allowed domains -- one per line, every site that will embed this
instance's
[
{ "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" }
]- 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.
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:
- The site's domain must be in "Allowed domains".
/consent.jsresolves the requesting site from itsOrigin/Refererheader and refuses (404) anything not on that list -- match it exactly (bare host, no scheme, e.g.www.example.com). - The c15t backend's own
trustedOriginsmust 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.
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].
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-triggerto 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.
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).
| 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.
npm install
npm run build # -> Assets/build/consent-bundle.jsRe-run and commit the result whenever anything under Assets/src/ changes.
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.