Most CMPs ship a pre-built GTM template. So why build your own? Because you're running a homegrown consent solution with no vendor template. Because you need consent logic that no off-the-shelf template supports. Or because debugging a template you didn't write is a special kind of frustrating.
This tutorial walks through building a Consent Mode v2 template from scratch in GTM's Template Editor: the sandboxed consent APIs, the permission model, initialization and update flows, wait_for_update, testing, and publishing. For a broader overview of GTM's consent architecture, start with the complete GTM consent setup guide. This article focuses on template authoring.
Why Build a Custom Template Instead of Using a CMP's
Vendor templates work for most teams, but they're black boxes. When consent signals don't reach GA4 correctly, you're reverse-engineering someone else's sandboxed code. If the vendor maps consent categories differently than you expect, or doesn't support a consent type you need, you're stuck filing a feature request.
Custom templates give you direct control over three things:
- Consent type mapping. You decide which user choices map to which Google consent types. A "Statistics" category controlling both
analytics_storageandad_user_data? You write that mapping. - Regional defaults. Call
setDefaultConsentStatemultiple times with differentregionarrays for per-geography defaults. - Update timing. You control exactly when
updateConsentStatefires and how returning visitors' stored consent is restored.
The cost is maintenance. When Google changes API behavior, you own the update. If you're here, you've already made that call.
The GTM Template Editor: Quick Orientation
Open your GTM container, go to Templates in the left sidebar, and click New under Tag Templates. The editor has four tabs.
Info sets the template's name, description (200 characters max), and icon (square PNG or JPEG, at least 64x64 pixels, under 50KB). Name it something descriptive, like "Custom Consent Mode v2."
Fields is where you build the tag's configuration UI. You'll add dropdowns, text inputs, and checkbox groups that appear when someone creates a tag from your template. For a consent template, you'll typically need a command selector (default vs. update), fields for each consent type's default value, a wait_for_update input, and optionally a region field.
Code is where you write sandboxed JavaScript. This isn't regular browser JS: you can't access window or document directly. Instead, you use GTM's sandboxed APIs via require() calls. The data object holds the values from your Fields tab.
Permissions declares what your template is allowed to do. This is where consent templates differ from standard tag templates, and getting it wrong means silent failures with no error messages.
The Consent Mode API in GTM Templates
GTM exposes four consent-related APIs to custom templates. You import each one with a require() call.
setDefaultConsentState
Sets the initial consent state before the user interacts with your banner. Call it once, at template execution time, when the template fires on the Consent Initialization – All Pages trigger.
const setDefaultConsentState = require('setDefaultConsentState');
setDefaultConsentState({
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'analytics_storage': 'denied',
'functionality_storage': 'denied',
'personalization_storage': 'denied',
'security_storage': 'granted',
'wait_for_update': 500
});The function accepts an object mapping consent type strings to 'granted' or 'denied'. Two special keys exist: region (an array of ISO 3166-2 codes) and wait_for_update (a number in milliseconds). It doesn't return anything.
updateConsentState
Pushes a consent update after the user makes a choice. GTM processes the update immediately after the current event and its associated tags finish.
const updateConsentState = require('updateConsentState');
updateConsentState({
'ad_storage': 'granted',
'ad_user_data': 'granted',
'ad_personalization': 'granted',
'analytics_storage': 'granted'
});Same signature as setDefaultConsentState, minus the wait_for_update and region keys.
isConsentGranted
Returns a boolean indicating whether a specific consent type is currently granted. Useful for conditional logic inside the template.
const isConsentGranted = require('isConsentGranted');
if (isConsentGranted('analytics_storage')) {
// analytics consent is active
}addConsentListener
Registers a callback that fires whenever a specific consent type changes state. The callback receives the consent type string and a boolean (true if now granted).
const addConsentListener = require('addConsentListener');
addConsentListener('ad_storage', function(consentType, granted) {
if (granted) {
// ad_storage just flipped to granted
}
});You won't use addConsentListener in most consent templates (it's more useful in tag templates that need to react to consent changes). But it's part of the API surface and worth knowing about.
Template Permissions: "Accesses Consent State"
This is the step that trips up most first-time template authors. Every custom template that calls setDefaultConsentState, updateConsentState, or isConsentGranted must declare the "Accesses consent state" permission in the Permissions tab. Without it, the API calls fail silently. No error in the console, no warning in Preview mode. Your template runs, appears to succeed, and consent is never set.
To configure it:
- Open the Permissions tab in the Template Editor.
- Click Accesses consent state.
- Add each consent type your template will read or write:
ad_storage,ad_user_data,ad_personalization,analytics_storage,functionality_storage,personalization_storage,security_storage. - For each type, check Write (since your template is setting/updating consent state). If your template also reads consent state via
isConsentGranted, check Read as well.
If your template reads cookies to detect returning visitors' stored consent, you'll also need the "Reads cookie value(s)" permission. Under "Specific," list the exact cookie names your consent solution uses (e.g., consent_preferences or cookie_consent_status).
A common mistake: granting permission for some consent types but not all. If your template writes to ad_personalization but you forgot to add that type to the permission list, only that specific type will silently fail while everything else works.
Building the Consent Initialization Logic
The initialization flow runs when a visitor's first page loads. Your template needs to do two things: set consent defaults for visitors who haven't chosen yet, and restore stored consent for returning visitors who already made a choice.
Step 1: Define the Template Fields
In the Fields tab, create these fields:
- Command (dropdown): "Default" or "Update". This lets you use the same template for both flows by creating two tag instances.
- Default consent per type (a group of dropdowns): One dropdown per consent type, each with options "Granted" and "Denied." Most sites default everything to
deniedfor EEA compliance. - Wait for update (text input, number): Milliseconds to wait for a consent update before tags fall back to defaults. Default to 500.
- Region (text input, optional): Comma-separated ISO 3166-2 codes (e.g., "AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE"). Leave blank for global defaults.
Step 2: Write the Default Command Code
In the Code tab, handle the "Default" command path:
const setDefaultConsentState = require('setDefaultConsentState');
const getCookieValues = require('getCookieValues');
const updateConsentState = require('updateConsentState');
const JSON = require('JSON');
// Read field values
const command = data.command;
if (command === 'default') {
// Build the consent defaults object
const consentSettings = {
'ad_storage': data.adStorage || 'denied',
'ad_user_data': data.adUserData || 'denied',
'ad_personalization': data.adPersonalization || 'denied',
'analytics_storage': data.analyticsStorage || 'denied',
'functionality_storage': data.functionalityStorage || 'denied',
'personalization_storage': data.personalizationStorage || 'denied',
'security_storage': data.securityStorage || 'granted'
};
// Add wait_for_update if specified
if (data.waitForUpdate) {
consentSettings.wait_for_update = data.waitForUpdate;
}
// Add region if specified
if (data.region) {
consentSettings.region = data.region.split(',');
}
setDefaultConsentState(consentSettings);
// Check for stored consent from a returning visitor
const storedConsent = getCookieValues('consent_preferences');
if (storedConsent && storedConsent.length > 0) {
const parsed = JSON.parse(storedConsent[0]);
if (parsed) {
updateConsentState({
'ad_storage': parsed.ad_storage || 'denied',
'ad_user_data': parsed.ad_user_data || 'denied',
'ad_personalization': parsed.ad_personalization || 'denied',
'analytics_storage': parsed.analytics_storage || 'denied',
'functionality_storage': parsed.functionality_storage || 'denied',
'personalization_storage': parsed.personalization_storage || 'denied'
});
}
}
}
data.gtmOnSuccess();This code sets the default consent state, then immediately checks if the visitor has a stored consent cookie. If they do, it parses it and calls updateConsentState to restore their previous choices. This way, returning visitors don't see the banner again and their tags behave correctly from the first pageview.
Notice the data.gtmOnSuccess() call at the end. Every tag template must call either data.gtmOnSuccess() or data.gtmOnFailure() to tell GTM the tag finished executing. Forgetting this makes the tag appear "still firing" indefinitely in Preview mode.
Handling Consent Updates
When a visitor clicks your cookie banner's accept, reject, or save-preferences button, your banner script needs to push an event to the data layer. The update instance of your template listens for that event and calls updateConsentState.
The Data Layer Event
Your banner should push something like this when the user makes a choice:
window.dataLayer.push({
event: 'consent_update',
consent_preferences: {
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'denied',
analytics_storage: 'granted',
functionality_storage: 'granted',
personalization_storage: 'denied'
}
});The Update Command Code
Add an else branch to your template code for the "Update" command:
if (command === 'update') {
const copyFromDataLayer = require('copyFromDataLayer');
const prefs = copyFromDataLayer('consent_preferences');
if (prefs) {
updateConsentState({
'ad_storage': prefs.ad_storage || 'denied',
'ad_user_data': prefs.ad_user_data || 'denied',
'ad_personalization': prefs.ad_personalization || 'denied',
'analytics_storage': prefs.analytics_storage || 'denied',
'functionality_storage': prefs.functionality_storage || 'denied',
'personalization_storage': prefs.personalization_storage || 'denied'
});
}
data.gtmOnSuccess();
}Creating the Update Tag Instance
In your GTM container, create a second tag using your custom template:
- Set the Command dropdown to "Update."
- Create a Custom Event trigger for the event name
consent_update. - Assign that trigger to this tag instance.
Now the flow works end to end: the Default tag fires on Consent Initialization and sets denied defaults. The user sees the banner, makes a choice. Your banner script pushes consent_update to the data layer. The Update tag fires, calls updateConsentState, and every Google tag that was waiting in restricted mode immediately adjusts its behavior.
The wait_for_update Parameter and Timeout Handling
wait_for_update tells Google tags how long (in milliseconds) to wait for a consent update before falling back to defaults. The flow:
- Your Default tag calls
setDefaultConsentStatewithwait_for_update: 500. - Google tags see
denieddefaults but hold off sending restricted pings for up to 500ms, waiting for anupdateConsentStatecall. - If an update arrives in time (because a returning visitor's consent cookie was parsed), tags use the updated state directly. No restricted pings sent.
- If the timeout expires without an update, tags fall back to defaults and send restricted, cookieless pings.
The key insight: wait_for_update primarily matters for returning visitors. A well-implemented template parses the consent cookie and calls updateConsentState synchronously inside the Default tag, completing in single-digit milliseconds. For first-time visitors who haven't seen the banner yet, tags will always fall back to defaults (there's no stored consent to read).
What value should you use? Google recommends 500ms. Below that risks timing out before cookie parsing finishes on slow connections. Above 2000ms delays pageview tracking on quick bounces. 500ms is generous for synchronous cookie reads. This is not a user-facing delay; it's an internal hold on Google's tag execution, invisible to visitors.
Testing the Template in GTM Preview Mode
Click Preview in your GTM workspace and enter your site URL. Tag Assistant opens alongside your site.
Check the Consent event. In Tag Assistant's event timeline, find the Consent event and verify your Default tag instance appears under "Tags Fired." Click the Consent tab to confirm all consent types show their expected default values. Any type showing "Not set" means you're missing a permission entry for it.
Simulate an update. Click accept on your cookie banner. A new event (consent_update) should appear in the timeline. Verify the Update tag fired and that consent values changed in the Consent tab.
Test returning visitors. Reload the page without clearing cookies. The Consent tab on the Consent event should show the final (updated) values, not the defaults. This confirms your cookie-reading logic fires within the wait_for_update window.
Common Problems
- "Not set" on a consent type: Missing "Accesses consent state" permission for that type.
- Wrong default values: Field names in the Fields tab don't match the
dataproperty names in your code. - Update tag doesn't fire: The Custom Event trigger name doesn't match what your banner pushes to the data layer.
- Third-party tags still firing despite denied consent: Consent Mode defaults only affect Google tags natively. Non-Google tags need explicit Additional Consent Checks configured.
Publishing and Sharing via the Community Template Gallery
For internal use, export the template (three-dot menu in the Template Editor) and import the .tpl file into other GTM containers. For public distribution, submit to the Community Template Gallery.
Gallery submission requires a public GitHub repository with three files at the root:
template.tpl(your exported template)metadata.yaml(homepage URL, documentation URL, version info)LICENSE(Apache 2.0, filename in all caps)
Enable the Issues section on the repo (Google requires it for support), then visit tagmanager.google.com/gallery and click Submit Template. Google reviews for quality and style guide compliance; approval typically takes a few weeks. Once approved, users find your template by searching the Gallery inside GTM.
Alternatively, host the .tpl file on GitHub and link to it directly. Users download and import manually, skipping the review process.
CookieBeam's GTM Template: Skip the Custom Build
If building a custom template isn't worth the maintenance burden, CookieBeam ships an official GTM template that handles all of this. It supports all seven Google consent types, Microsoft Consent Mode (Clarity and UET), and regional defaults mapped to your dashboard's regional rules.
Setup is one tag: create it from the CookieBeam template, paste your Banner ID, assign to the Consent Initialization trigger. The template reads your banner config from the CDN, sets region-appropriate defaults, restores returning visitors' consent, and calls updateConsentState when the user interacts with the banner. No data layer events to wire, no cookie parsing to write.
Import from the GitHub repository (Gallery submission pending). Full instructions in the GTM Integration Guide.