Ad and analytics vendors integrated through a Transparency & Consent Framework CMP never receive the TC string, so they take the worst available default: privacy-conservative SDKs self-suppress and stop delivering revenue, while looser ones fire without a legal basis and expose you to a compliance finding. The gap is almost always that vendors were dropped onto the page but never registered as TCF listeners, so the consent signal is generated and simply never propagated.
Triage: Confirm __tcfapi Exists and Returns a TC String
The TCF CMP exposes exactly one global function, window.__tcfapi. If it is missing, or present but returning an empty string, no downstream vendor can act on consent.
- Open Chrome DevTools → Console and confirm the API surface exists:
console.log('CMP present:', typeof window.__tcfapi === 'function');
- Ask the CMP for the current consent payload. The
getTCDatacommand is the canonical read; the version argument must be2for TCF v2.x:
window.__tcfapi('getTCData', 2, (tcData, success) => {
console.log('success:', success);
console.log('eventStatus:', tcData && tcData.eventStatus); // tcloaded / cmpuishown / useractioncomplete
console.log('gdprApplies:', tcData && tcData.gdprApplies);
console.log('tcString length:', tcData && tcData.tcString && tcData.tcString.length);
});
- If
successisfalseortcStringis empty whilegdprAppliesistrue, the CMP has not finished resolving — check that you are calling after the stub is installed. Ifsuccessistrueand the string is populated but your vendor still does nothing, the vendor is not listening for the payload. - In the Network tab, filter for the vendor’s endpoint (
googlesyndication.com,pubmatic.com,criteo.com). A vendor firing requests with nogdpr_consentparameter is firing non-compliantly; a vendor with zero requests despite a valid TC string is self-suppressing. Either confirms broken propagation.
Reproduction checklist:
Root Cause: Vendors Are Not Registered as TCF Listeners
The TCF v2.2 CMP API is a publish–subscribe contract, not a one-shot read. The CMP encodes user choices into a base64url TC string — a compact record of which of the 11 standard purposes and which Global Vendor List (GVL) vendor IDs the user consented to. That string is authoritative, but the CMP does not push it anywhere on its own. Every downstream vendor must call __tcfapi('addEventListener', 2, cb) to be handed the payload on load and again on every subsequent change.
Vendors self-suppress or misfire for three structural reasons:
- No subscription. A vendor tag dropped in via a tag manager but never wired to
addEventListenersees no consent and, per its own compliance defaults, either refuses to fire or fires without thegdpr_consentparameter. - Reading once instead of listening. A vendor that calls
getTCDataa single time during init reads a pre-consent state (eventStatus: 'tcloaded'or'cmpuishown') and caches a deny, never re-checking when the user accepts. - No purpose/vendor gate. Even a vendor that receives the string but does not inspect
tcData.purpose.consentsandtcData.vendor.consentscannot know whether its GVL ID and its required purposes were granted.
This is the vendor-propagation half of the broader problem covered in the syncing consent states across multiple vendors guide: the CMP holds the truth, and each vendor needs a subscription plus a purpose check to consume it correctly.
Resolution: Register a Listener and Gate on Purpose + Vendor Consent
The correct integration registers each vendor as a listener, ignores pre-consent events, and only fires once the required purposes and the vendor’s own GVL ID are consented. This single pattern replaces every ad-hoc “read once and hope” integration.
// Register once, as early as the CMP stub allows. TCF v2.2, API version 2.
// The callback re-runs on EVERY consent change, not just the first.
function registerVendor({ vendorId, requiredPurposes, onGranted, onDenied }) {
if (typeof window.__tcfapi !== 'function') {
// No TCF CMP on this page — fall back to your non-GDPR path.
onDenied('no_cmp');
return;
}
window.__tcfapi('addEventListener', 2, (tcData, success) => {
if (!success) return; // malformed call — do nothing, stay suppressed
// Only act on resolved states. tcloaded/cmpuishown are pre-decision.
const resolved =
tcData.eventStatus === 'tcloaded' || // consent already stored from a prior visit
tcData.eventStatus === 'useractioncomplete'; // user just interacted this session
if (!resolved) return;
// Outside GDPR scope: the framework does not apply, proceed unconditionally.
if (tcData.gdprApplies === false) {
onGranted(tcData.tcString);
return;
}
// Purpose consents: object keyed by purpose id (1..11) -> boolean.
const purposesOk = requiredPurposes.every(
(p) => tcData.purpose && tcData.purpose.consents && tcData.purpose.consents[p] === true
);
// Vendor consent: keyed by the vendor's Global Vendor List id -> boolean.
const vendorOk =
tcData.vendor && tcData.vendor.consents && tcData.vendor.consents[vendorId] === true;
if (purposesOk && vendorOk) {
onGranted(tcData.tcString);
} else {
onDenied('purpose_or_vendor_denied');
}
});
}
Wire a concrete vendor to it. Purposes 1 (store/access information on a device), 3 (create personalised ads profile), and 7 (measure ad performance) are the common set for a programmatic ad partner; pass the vendor’s real GVL ID:
registerVendor({
vendorId: 755, // Google Advertising Products GVL id (example)
requiredPurposes: [1, 3, 7],
onGranted(tcString) {
// Propagate the TC string INTO the vendor call so it fires compliantly.
// Most endpoints accept it as the gdpr_consent parameter alongside gdpr=1.
loadVendorScript(
`https://securepubads.g.doubleclick.net/gpt/pubads_impl.js` +
`?gdpr=1&gdpr_consent=${encodeURIComponent(tcString)}`
);
},
onDenied(reason) {
console.info('[tcf] vendor 755 suppressed:', reason);
// Leave the tag unloaded. Do NOT fire an un-consented fallback.
},
});
// Idempotent injector — the listener re-fires, so guard against double-load.
function loadVendorScript(src) {
if (document.querySelector(`script[data-tcf-src]`)) return;
const s = document.createElement('script');
s.src = src;
s.async = true;
s.dataset.tcfSrc = '1';
document.head.appendChild(s);
}
Two details make this compliant rather than merely functional. First, the TC string is passed into the vendor request as gdpr_consent, so the vendor and every partner it calls downstream can independently validate the same signal against the GVL — the canonical registry of consented vendor IDs. Second, because the CMP API v2.2 re-invokes the listener on useractioncomplete, a user who grants consent after page load is picked up without a reload, exactly like the revocation flow in handling consent revocation without page reload. When consent is later withdrawn, the same callback fires with the purposes now false and your onDenied path runs.
Verification
After a full banner acceptance, confirm the string is both readable from the CMP and present on the outbound vendor request:
window.__tcfapi('getTCData', 2, (tcData, ok) => {
console.assert(ok && tcData.eventStatus === 'useractioncomplete', 'not resolved');
console.assert(tcData.purpose.consents[1] === true, 'purpose 1 not granted');
console.assert(tcData.vendor.consents[755] === true, 'vendor 755 not granted');
console.log('TC string:', tcData.tcString);
});
Then open the Network tab, find the vendor request, and confirm its query string carries gdpr=1&gdpr_consent= followed by the same TC string the console logged. A matching, non-empty gdpr_consent parameter on a request that previously fired bare (or did not fire at all) is the single observable proof that consent now propagates end to end. Decoding that string in the IAB TCF decoder should show your consented purposes and vendor 755 flipped on.
Common Pitfalls
- Acting on
cmpuishownor an earlytcloadedwith an empty string. Firing the vendor before the user decides sends a request with no consent basis. Gate onuseractioncomplete, or ontcloadedonly whentcStringis already populated from a stored prior choice. - Checking purposes but not the vendor’s GVL ID. A user can consent to purpose 3 globally while denying your specific vendor. Both
tcData.purpose.consents[p]andtcData.vendor.consents[vendorId]must betruebefore firing. - Reading once instead of subscribing. A single
getTCDataat init caches the pre-consent state forever. Always useaddEventListenerso the callback re-runs on every change, and make the loader idempotent so repeat invocations neither double-inject nor drop a late grant.
Related
- Syncing Consent States Across Multiple Vendors — the governing model for keeping every vendor on one consent signal
- Handling Consent Revocation Without Page Reload — tearing vendors down when the same TCF listener reports withdrawal
- Architecting GDPR-Compliant Consent Gating — the upstream state machine that produces the TC string
- Regional Routing for CCPA and Global Privacy Laws — deciding when TCF applies versus a US opt-out regime