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).
Gate a feature on the visitor's consent instead of loading it unconditionally -- e.g. only mount a support-chat widget once the "functionality" category is granted.
window.ccm.hasConsent(category)-- returnstrue/falsefor whether that category is currently granted. Accepts either a bare category string (window.ccm.hasConsent('measurement')) or a compound condition object ({ and: [...] }/{ or: [...] }/{ not: ... }), forwarded straight to c15t's ownstore.has().window.ccm.onConsentChange(callback)-- subscribescallbackto any change in consent state (not just a save -- fires on every state transition). Returns an unsubscribe function.callbackreceives no arguments; re-checkhasConsent()inside it rather than assuming what changed.
if (window.ccm.hasConsent('functionality')) {
mountWidget();
}
const unsubscribe = window.ccm.onConsentChange(() => {
if (window.ccm.hasConsent('functionality')) mountWidget();
else unmountWidget();
});Both are assigned once /consent.js finishes loading and executing. A script
tag added with defer (recommended) won't fire its load event until that's
done, so wait for that event before calling either of these if you're loading
the script yourself rather than embedding it via a template that already
guarantees ordering.
| 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.