Consent bridge and identity reconnection
The hosted tag records from page load by default. If your site shows a cookie or consent banner, hold it: the SDK must start paused until consent, with data-telltale-wait-for-consent on the tag or waitForConsent: true in init() (the bridge below does this). EU and UK sites usually need a consent banner for this kind of measurement and should hold. A site with no banner keeps the default tag and can skip this guide; a visitor's saved refusal and optOut() are still honored. Your consent manager (CMP) owns the choice, the purposes and the consent record. Telltale enforces the decision you apply: a start that is paused until consent makes no SDK requests and writes no tracking state, optOut() cancels owned work, discards pending telemetry and revokes any session saved by an earlier grant with one token-only beacon, and optIn() starts fresh collection.
The page-owned bridge
A CMP can report a decision before the SDK has loaded, and a withdrawal can arrive while an earlier grant is still being applied. Use one small bridge, owned by the page rather than by a component, that loads the SDK held and always applies the latest decision. Save the code below as /telltale-consent.js on your own origin (the Telltale server does not serve it, because it holds your key and your endpoint) and load it once, before your application entry and before your CMP.
// /telltale-consent.js
(() => {
if (window.telltaleConsent) return;
const ENDPOINT = 'https://app.trytelltale.dev';
const KEY = 'YOUR_SITE_PUBLISHABLE_KEY';
let client = null;
let latest; // undefined until the CMP reports a decision
const report = error => {
if (error?.name !== 'AbortError') console.error('Telltale consent', error);
};
function applyConsent(allowed) {
if (typeof allowed !== 'boolean') throw new TypeError('applyConsent expects the CMP decision as a boolean');
latest = allowed; // a later withdrawal always replaces an earlier grant
if (client) (allowed ? client.optIn() : client.optOut()).catch(report);
}
const ready = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = ENDPOINT + '/sdk.js';
const nonce = document.currentScript?.nonce;
if (nonce) script.nonce = nonce;
script.onload = () => {
try {
client = window.Telltale.init({ endpoint: ENDPOINT, key: KEY, waitForConsent: true, discover: true });
if (latest !== undefined) applyConsent(latest);
resolve(client);
} catch (error) {
reject(error);
}
};
script.onerror = () => reject(new Error('Telltale SDK did not load'));
document.head.append(script);
});
ready.catch(report);
// Read-only CMP detection: the presence and readiness rules of /adapters/v1/cmp.js (adapted from duckduckgo/autoconsent
// with the owner's permission). It never clicks, never calls a CMP method and never changes a consent choice.
const find = selector => { try { return document.querySelector?.(selector) || null; } catch { return null; } };
const shown = el => !!el && (el.offsetParent != null || (window.getComputedStyle?.(el)?.position === 'fixed' && window.getComputedStyle(el).display !== 'none'));
const probes = {
onetrust() {
const expected = Array.from(document.scripts || []).some(s => /cookielaw\.org|otSDKStub|otBannerSdk/.test(s.src || s.textContent || ''));
const style = !!find('#onetrust-style'), banner = find('#onetrust-banner-sdk'), center = find('#onetrust-pc-sdk');
const api = typeof window.OneTrust?.IsAlertBoxClosed === 'function' && typeof window.OnetrustActiveGroups === 'string';
return { present: !!banner || shown(center) || expected || style || api, ready: api && (style || !expected), popupVisible: shown(banner) || shown(center) };
},
cookiebot() {
const cb = window.Cookiebot;
return { present: !!cb || !!find('#CybotCookiebotDialog'), ready: !!cb && typeof cb.hasResponse === 'boolean' && !!cb.consent && typeof cb.consent === 'object', popupVisible: !!cb && cb.hasResponse !== true && cb.dialog?.visible === true };
},
sourcepoint() {
const box = find("div[id^='sp_message_container_']"), tcf = typeof window.__tcfapi === 'function', usnat = typeof window._sp_?.usnat?.getUserConsents === 'function';
return { present: !!box || !!window._sp_, ready: tcf || usnat, popupVisible: shown(box), apis: { tcf, usnat } };
}
};
function detect() {
const cmps = Object.entries(probes).map(([name, probe]) => { try { return { name, ...probe() }; } catch { return null; } }).filter(c => c && (c.present || c.ready));
return { cmp: (cmps.find(c => c.ready) || cmps[0])?.name || null, cmps };
}
function cmpReady(name, { timeoutMs = 10000, intervalMs = 100, signal } = {}) {
const started = Date.now();
return new Promise(resolve => {
(function tick() {
const found = detect().cmps.find(c => c.ready && (!name || c.name === name));
if (found) return resolve({ name: found.name, ready: true });
if (signal?.aborted) return resolve({ name: name ?? null, ready: false, reason: 'aborted' });
if (Date.now() - started >= timeoutMs) return resolve({ name: name ?? null, ready: false, reason: 'timeout' });
setTimeout(tick, intervalMs);
})();
});
}
window.telltaleConsent = { applyConsent, ready, cmp: { detect, ready: cmpReady } };
})();window.telltaleConsent is your code, not an SDK API. ready resolves when the held client exists; it does not mean collection is permitted. Decisions made before the SDK loads are held in latest and applied once, so only the newest one counts. Keep the bridge alive across SPA routes: never grant from a component mount, and never treat a component unmount as withdrawal.
Then load exactly one CMP recipe (OneTrust, Cookiebot or Sourcepoint) after the bridge. The OneTrust and Cookiebot recipe files detect window.telltaleConsent and send every decision through applyConsent instead of creating their own client; for your own CMP code, call it directly:
window.telltaleConsent.applyConsent(true); // the CMP approved the required purposes
window.telltaleConsent.applyConsent(false); // refused or withdrawnWhich CMP is on the page, and when it is ready
telltaleConsent.cmp tells your code which consent manager the page has and when its browser API can be read. It only reads: it never clicks a banner, never calls a CMP method and never changes a consent choice, so it cannot accept or refuse anything on the visitor's behalf.
const { cmp, cmps } = window.telltaleConsent.cmp.detect();
// cmp: 'onetrust', 'cookiebot', 'sourcepoint' or null
// cmps: [{ name, present, ready, popupVisible, apis? }] for every CMP found
const state = await window.telltaleConsent.cmp.ready('onetrust', { timeoutMs: 10000 });
if (!state.ready) console.warn('OneTrust did not load', state.reason); // 'timeout' or 'aborted': stay held, call nothing- present means the CMP's markup, script or global is on the page. ready means its API can be read: OneTrust once OneTrust.IsAlertBoxClosed and OnetrustActiveGroups exist (and, when the page loads the OneTrust SDK, once the SDK has injected #onetrust-style); Cookiebot once window.Cookiebot has hasResponse and consent; Sourcepoint once __tcfapi or _sp_.usnat.getUserConsents exists. popupVisible means the CMP's banner is showing; it is a fact for diagnostics, not a decision.
- ready() never rejects and never grants. On a timeout, or when you abort its signal, it resolves with ready: false; keep collection held. Ready is not consent: read the decision from the CMP's API afterwards, as the recipes do.
- The same rules ship as an ES module for bundled applications, /adapters/v1/cmp.js (detectCmp and waitForCmp; the server adapters guide lists its served URL and integrity). It also recognises Sourcepoint's own message frames. The rules are adapted from duckduckgo/autoconsent under the owner's written permission, which ships with Telltale; only its presence and readiness detection is used, never its opt-in or opt-out rules.
Apply decisions correctly
- Read the CMP's saved decision on every full page load and subscribe to later changes.
- An unknown initial choice stays held: call nothing.
- Approval calls optIn(); refusal and withdrawal call optOut(). Once a decision has been applied on the page, losing it (for example the CMP reports no valid decision any more) is a withdrawal.
- start(), identify(), reset() and getSessionToken() never grant permission, and signing in never grants it.
- A site-provided "withdraw consent" button must call optOut() (or applyConsent(false)) synchronously in the same handler, before it asks the CMP to withdraw.
Reconnect current identity
Withdrawal discards the SDK's identity. After every grant, read identity again from your application's current authentication; never replay an identity captured before the withdrawal, and never let an older asynchronous auth read win over a newer change.
connectIdentity in browser.js does this. Download it into your application (no npm package is published; Server adapters lists its SHA-256):
curl -sSo lib/telltale/browser.js https://app.trytelltale.dev/adapters/v1/browser.jsimport { connectIdentity } from './lib/telltale/browser.js';
const client = await window.telltaleConsent.ready;
const identity = connectIdentity(client, {
// Read the current session from your own auth owner. Return null when signed out.
readIdentity: async ({ signal }) => {
const response = await fetch('/api/session', { signal, credentials: 'same-origin' });
if (!response.ok) return null;
const session = await response.json();
return session.user ? { userId: session.user.id, accountId: session.organization?.id ?? null } : null;
},
// Call onChange on login, logout and account switch. Return an unsubscribe function.
subscribe: onChange => auth.onChange(onChange)
});
// identity.dispose() only when the auth owner itself is torn down.connectIdentity subscribes to onConsentChange. If you subscribe yourself, note that the listener receives an object, {allowed, storage}, not a boolean: it is called synchronously with the current state and then only when allowed changes. Branch on state.allowed (an if (state) check would also run on withdrawal), and treat storage: 'memory' as a choice that lasts only until reload. With connectIdentity, withdrawal invalidates pending reads and forgets the identity; a grant triggers a fresh read. A changed user or account resets the SDK session before the replacement is identified. It never calls optIn(). The Better Auth adapter is built on it; do not add a second identity owner beside it.
The React and Next.js and the Vue and Nuxt guides show where the bridge and this subscription go in each framework.
Reading an IAB GPP string on your server
If your CMP writes an IAB Global Privacy Platform (GPP) string, for example in a __gpp call result or a cookie your server receives, gpp.js can decode it in memory. It is one dependency-free module with no network access; download it into your server (Server adapters lists its SHA-256):
curl -sSo lib/telltale/gpp.js https://app.trytelltale.dev/adapters/v1/gpp.jsNothing in the Telltale server reads GPP strings for you, and the SDK still only acts on the decision you apply.
import { gppConsentSignals } from './lib/telltale/gpp.js';
const signals = gppConsentSignals(gppString, { tcfPurposes: [1] });
// signals.decision is 'granted', 'denied' or 'unknown'
// 'granted' -> applyConsent(true); 'denied' -> applyConsent(false); 'unknown' -> call nothing (stay held)- It decodes the GPP v1 header, TCF EU v2, US National (usnat, versions 1 and 2, including the GPC segment) and the legacy US Privacy section (uspv1). Other sections, such as the US state sections and TCF Canada, are listed in
sectionsbut not decoded, and do not affect the decision. grantedneeds a TCF EU v2 section that gives consent for every purpose intcfPurposes(default: purpose 1, storing or accessing information on a device), and no US opt-out. Legitimate interest never counts as consent.deniedmeans a required TCF purpose lacks consent, or a US sale, sharing or targeted-advertising opt-out or GPC is set. PasshonourUsOptOuts: falseif your counsel decides those opt-outs do not cover this collection; the decision is thenunknowninstead.- US opt-out sections on their own never produce
granted: an opt-out regime is not a grant. Choose your default for US visitors yourself. - A malformed, truncated or oversized string returns
unknownwith reasonundecodable, never a grant. Decode errors never include the input. - The result holds only categorical fields (
decision,reason, section names, the required and granted TCF purpose ids, and US opt-out states). Do not store or log the raw GPP string or the decoded vendor lists; keep only what you need to apply the decision.
decodeGpp() returns the full decoded fields for debugging. It is adapted from IABTechLab/iabgpp-es under the Apache License 2.0; the licence notice is at the top of the file.
Google Tag Manager and Cloudflare
- Google Tag Manager: enable Let my CMP control collection on the template, choose the consent types, and fire the tag on CMP decision events (Google Tag Manager guide). Do not add this bridge as well.
- Cloudflare Zaraz: the managed installation starts automatically and does not follow a CMP. Remove it before using this bridge for the same Site.
What withdrawal cannot do
Withdrawal stops owned collection and tools and discards pending evidence. It cannot recall requests already sent, tokens or form data already handed to your application, server-side identity associations or history. Withdrawal also revokes the SDK session on the Telltale server with one keepalive POST to /api/sdk/revoke that carries only the publishable key and the session token, never evidence. It is the only request a withdrawal makes, and nothing follows it; afterwards verify answers session_reinit_required for that token, so a token captured before withdrawal stops verifying. If the beacon cannot be delivered (offline, closed tab), the token stays verifiable until it expires. If browser storage fails, the page keeps the choice in memory until reload; durable refusal cannot be guaranteed, which is why consent-managed pages always start held and reapply the CMP decision.
Private browsing and where the decision is stored
The SDK keeps the decision in the page origin's localStorage (key observatory:<collector>:<key>:consent, a historical name, stable and kept on purpose so existing installs keep working; see Renamed names; the Browser SDK guide lists every key), written only when your code calls optIn() or optOut(). The hosted script runs in your page, so this is your origin's storage, not third-party storage, even though sdk.js is downloaded from the Telltale server. No cookie is set and the SDK does not detect private mode.
Private and incognito windows normally allow localStorage for the life of that private session and clear it when the window closes, so the next private session starts with no saved decision, stays held and needs your CMP to apply the choice again. Where a browser refuses storage, the SDK keeps the decision in memory until reload and getConsent() reports storage: 'memory'. Consent-managed pages therefore always start held and reapply the CMP decision on every load.
Storage used only to remember a consent choice may fall under an exemption in device-storage rules (for example the UK ICO's guidance on storage exemptions). That does not exempt the session token or any other storage, and it does not decide your lawful basis for processing personal data. Your legal team sets the policy for your deployment.
Test your setup
Test a fresh visitor, a saved choice, partial approval, refusal, withdrawal, reapproval, repeated callbacks, no CMP, both CMP and SDK load orders, a slow auth read that resolves after a withdrawal, reload, SPA navigation and a second tab. A held page may download sdk.js but must make no collector requests.