BuildBaseBuildBase

Consent

Consent Mode v2 defaults by region, the manifest your banner renders from, and which tags wait for a yes.

Google's Consent Mode v2 defaults are pushed before any Google tag exists. Tags that honour Consent Mode load straight away with cookies off, and everything else waits for a yes.

import { useTracking } from '@buildbase/sdk/tracking';

const { consent } = useTracking();

consent.manifest; // only what is actually installed
consent.state; // the visitor's answer: { analytics, marketing } | null
consent.effective; // what is in force now: the answer, or this visitor's default
consent.default; // 'granted' | 'denied' - the default for where they are
consent.country; // the country our server saw, or null
consent.set({ analytics: true, marketing: false });

BuildBase ships no banner into your app. You own that UI - it belongs to your brand and your jurisdiction - and the SDK gives you the list to render and the signals to set.

Render your banner from the manifest

The manifest describes what is really on the page, so a visitor is never asked to consent to a vendor that will never load.

'use client';

import { useTracking, type ConsentState } from '@buildbase/sdk/tracking';

function CookieBanner() {
  const { consent } = useTracking();
  if (consent.state) return null;

  // Store the answer as well as giving it - see "Remember the answer yourself".
  const answer = (state: ConsentState) => {
    saveConsent(state); // your storage
    consent.set(state);
  };

  return (
    <div role="dialog" aria-label="Cookie preferences">
      <ul>
        {consent.manifest.map((v) => (
          <li key={v.key}>
            <a href={v.privacyUrl}>{v.name}</a> - {v.vendor} ({v.category})
          </li>
        ))}
      </ul>
      <button onClick={() => answer({ analytics: true, marketing: true })}>
        Accept all
      </button>
      <button onClick={() => answer({ analytics: true, marketing: false })}>
        Analytics only
      </button>
      <button onClick={() => answer({ analytics: false, marketing: false })}>
        Reject
      </button>
    </div>
  );
}

A library entry with no ID filled in is never in the manifest, and two GA4 properties produce one row - the same vendor, the same purpose and the same toggle, so a second row would be a choice nobody can act on.

What happens at each answer

For a visitor whose default is denied:

MomentData layerOn the page
Page loadconsent default - all four denied, wait_for_update: 500Consent Mode tags (GTM, GA4, Google Ads), cookies off
{ analytics: true, marketing: false }consent update - analytics_storage grantedplus analytics tags without Consent Mode (Clarity, ...)
{ analytics: true, marketing: true }consent update - all four grantedplus marketing tags without Consent Mode (Meta, ...)

wait_for_update: 500 gives your banner half a second to answer before Google's tags decide how to behave, which is what keeps a fast "accept" from being recorded as a denial.

Why Google tags load before a yes

A Google tag that is on the page with every signal denied sets no cookies and sends cookieless pings instead. Google uses those pings to model the conversions it cannot observe. A tag kept off the page until a yes sends nothing, so the conversions of everyone who never answered your banner are simply missing from Google Ads, not modelled.

So a vendor that honours Consent Mode loads in the denied state whatever its category, and stays on the page after a refusal, in the state that refusal allows. A vendor without Consent Mode (Meta, X, LinkedIn, TikTok, Reddit, Bing, Clarity, Hotjar and the rest) has no way to be present and not measure, so it waits for a yes in its category.

Whenever a Google tag is configured the SDK also sets:

  • url_passthrough: true, so the click id rides on internal links when cookies are denied and a conversion on a later page can still be tied to the ad.
  • ads_data_redaction: true while ad_storage is denied, so no ad click identifier goes with the cookieless pings. It is cleared when the visitor grants.

Needs @buildbase/sdk 0.0.72 or later. Earlier releases kept Google Ads off the page until marketing was granted.

Regional defaults

One global default forces a bad choice: denied everywhere loses measurement in countries where no law asks for consent first, and granted everywhere is not lawful in the EEA. A regional default applies the right one per visitor.

Set it per app in the console, under Auth › Clients › Edit › Consent. The recommended option, and the one the form pre-selects, is denied in the EEA, the UK and Switzerland and granted elsewhere. A client nobody has saved this on keeps the old behaviour, denied everywhere.

Or set it in code, which wins over the console:

<SaaSOSProvider
  tracking={{
    consent: { default: 'granted', regions: { denied: ['EEA', 'GB', 'CH'] } },
  }}
>
  • default covers everyone no region rule matches.
  • Regions are ISO 3166-1 countries (GB), ISO 3166-2 subdivisions (US-CA), or EEA, which expands to the 27 EU member states plus Iceland, Liechtenstein and Norway.
  • A subdivision rule beats its country, and a place listed under both denied and granted is denied.
  • consent.set() always replaces the default. A visitor in a granted region can still refuse.

The country comes from our server, never from the browser. The tracking-config request the SDK already makes returns the country the edge saw, and the default it resolves to. A timezone or a language is a setting the visitor chose, and both are wrong for every traveller and every VPN, so the SDK never guesses. When the server did not know the country, the default is denied.

The rule also reaches Google in its own regional syntax: a region-scoped consent default for each region list, then a global one for everyone else. Google applies the most specific match, so the tag is correct even where our lookup had no answer.

Warning

Which default is lawful for your app, and on what basis, is your decision. We recommend a default; we cannot make it for you. Regional defaults change what loads before a visitor answers, never what a visitor who answered gets.

Note

Consent categories come from the provider spec, not from the tag. Meta, Google Ads, X, LinkedIn, TikTok, Reddit and Bing default to marketing; GA4, Clarity, Hotjar, PostHog, Plausible and Ahrefs default to analytics. An admin can move an individual entry in the console.

Remember the answer yourself

The SDK keeps no record of the choice. consent.state starts as null on every page load, including the one that comes back from hosted sign-in, so an app that only calls consent.set from its banner's buttons measures every returning visitor as one who refused.

Store the answer where your banner already stores it, and hand it back on mount. Render RestoreConsent next to your banner, inside the provider:

'use client';

import { useEffect } from 'react';
import { useTracking, type ConsentState } from '@buildbase/sdk/tracking';

// Your storage and your expiry. localStorage is the simplest; a cookie on
// your registrable domain shares the answer across your subdomains.
function saveConsent(state: ConsentState) {
  localStorage.setItem('consent', JSON.stringify(state));
}
function readSavedConsent(): ConsentState | null {
  try {
    return JSON.parse(localStorage.getItem('consent') ?? 'null');
  } catch {
    return null;
  }
}

function RestoreConsent() {
  const { consent } = useTracking();
  const set = consent.set;

  useEffect(() => {
    const saved = readSavedConsent(); // your storage, your expiry
    if (saved) set({ analytics: saved.analytics, marketing: saved.marketing });
  }, [set]);

  return null;
}

Calling it this early is safe. An answer given before the tags exist is replayed the moment they do, directly after the denied default and ahead of any Google tag.

The answer travels to hosted sign-in

The hosted login and registration pages are on a different registrable domain from your app - ours, or your own auth domain. A cookie is readable only on the domain that wrote it, so the answer your banner stored cannot be read there, and a page that has to assume "not asked" is a page no ad platform can measure.

So the SDK sends it. When signIn() starts the flow, whatever consent.state holds at that moment goes with the request that mints the auth state, and the sign-in pages read it back from there.

It rides in the request body, not on the redirect URL. A consent answer in a URL ends up in server logs and Referer headers, and it is a value the visitor can edit into a "yes" they never gave.

Nothing is assumed when it is absent - an app that never asked, or one on an older SDK. The sign-in page then has no answer, Consent Mode defaults stay denied, and no marketing tag loads at all.

Note

This is one more reason to restore the answer on mount rather than only set it from your banner's buttons. A returning visitor whose consent.state is still null when they press sign-in sends nothing, and the sign-in page measures them as somebody who was never asked.

Every consent.set() also sends a record to BuildBase: the choices, the manifest version the visitor was shown, where it was given, and a consent string when your CMP has one. The server adds the time, the country the edge saw and a SHA-256 of the user agent. It never stores an IP address. The same answer restored on every page load is recorded once.

consent.set(
  { analytics: true, marketing: false },
  { source: 'banner', consentString: tcfString } // both optional
);

A record is filed under a random browser id (the bb_cid cookie, set only when the visitor answers) and, when they are signed in, under their account. To answer a data-subject request, look them up:

curl -H "Authorization: Bearer $TOKEN" \
  "$BUILDBASE_SERVER_URL/api/organizations/tracking/[email protected]"

Also by userId or visitorId. A signed-in user can read their own records at GET /api/v1/public/consent/mine.

The manifest version changes whenever you change the sign-up consent wording or enhanced conversions for a client, so a record says which wording it was given under.

Ask at sign-up instead of with a banner

Some apps would rather ask on the hosted sign-up page than with a site-wide banner. Turn on Ask for consent on the hosted sign-up page under Auth › Clients › Edit › Consent, and optionally set your own wording.

The page then shows one checkbox, apart from the terms sentence. It is never ticked by default, never required to create an account, and never bundled with the terms, because consent that is any of those is not valid consent. Ticking it applies the answer to that page's tags and records it with source: 'sign_up_prompt', filed under the new user once the sign-up completes.

Enhanced conversions

Turn on Enhanced conversions for a client in the console, and conversions from that app carry a SHA-256 hash of the signed-in user's email (and phone, if you provide one) to Google Ads and Meta. It is off by default.

  • Hashed in the browser, after Google's normalisation (trimmed, lower case, dots removed from a Gmail local part; phones in E.164). The plaintext is not kept and never leaves the browser.
  • Sent only when the visitor granted marketing consent, which is what grants ad_user_data.
  • Google receives it as gtag('set', 'user_data', { sha256_email_address, sha256_phone_number }) before each conversion; Meta as advanced matching.

SaaSOSProvider supplies the signed-in user's email for you. With BuildBaseTracking on its own, or to add a phone number:

const { setIdentity } = useTracking();
setIdentity({ email: user.email, phone: user.phone }); // hashed on the way in

setUser still takes only an id: ad networks forbid an email in a user-id field. An email or phone passed in setUser traits is taken out and hashed instead of being sent on.

Warning

Say in your privacy notice that hashed contact details are shared with your ad platforms for measurement before you turn this on.

If you already have a lawful basis

tracking={{ consent: 'granted' }} skips the denied defaults everywhere. It is your assertion, not ours, and it is the right setting only where you have obtained consent elsewhere - a server-rendered banner, a regional gate in front of the app. For "granted except where the law says otherwise", use a regional default instead.

<SaaSOSProvider tracking={{ consent: 'granted' }}>

tracking={{ consent: 'denied' }} keeps everything off, Consent Mode tags included, which is useful for a preview environment. On <BuildBaseTracking> the same option is a prop: consent="granted".

Attribution is not tracking

The click id in a landing page's URL is captured before any consent decision, and stored in a first-party cookie on your registrable domain so every subdomain shares it. Nothing is sent anywhere - it is keeping a value the visitor themselves arrived carrying, so a later, consented event can be attributed.

One record describes one visit, never a mix of two:

  • A visit with an ad click id (gclid, gbraid, wbraid, fbclid, msclkid and the rest) replaces what is stored. The newest click is the one ad platforms credit, and an old click id can have aged out of their window.
  • A visit with only UTM tags never replaces a stored click id. Someone who clicks an ad, comes back through a newsletter and then signs up still credits the ad.
  • A visit with nothing on the URL changes nothing. The record lasts 90 days, the window Google's own click cookie uses.

Needs @buildbase/sdk 0.0.68 or later. Earlier releases merged per parameter and could keep one click's gclid beside another click's utm_campaign.

These are all the parameters it keeps. Anything else on the URL is ignored.

ParameterKindWhat it records
refReferralWho sent this visitor, and who gets credited
viaReferralAffiliate partner who sent this visitor, in the affiliate tools' own param
utm_sourceCampaignWhich property or channel the visit came from
utm_mediumCampaignThe format within that source
utm_campaignCampaignWhich named piece of marketing this was
utm_idCampaignThe campaign's id in the ad platform, for joining to spend
utm_contentCampaignWhich variation inside one campaign
utm_termCampaignThe search term that was paid for
utm_source_platformCampaignThe platform the campaign was managed in
utm_creative_formatCampaignThe creative's format
utm_marketing_tacticCampaignWho the campaign targeted
gclidAd click idGoogle Ads click id
gbraidAd click idGoogle Ads iOS click id, app to web
wbraidAd click idGoogle Ads iOS click id, web to app
dclidAd click idGoogle Display & Video 360 click id
gad_sourceCampaignWhich Google surface the click came from
fbclidAd click idMeta click id
msclkidAd click idMicrosoft Advertising click id
li_fat_idAd click idLinkedIn click id
twclidAd click idX click id
ttclidAd click idTikTok click id
rdt_cidAd click idReddit click id
sccidAd click idSnapchat click id
epikAd click idPinterest click id
irclickidAd click idImpact affiliate click id

The domain is probed rather than computed: example.co.uk has three labels and example.com has two, so the SDK asks the browser which domain it will actually accept a cookie on instead of guessing.

Your privacy policy

The console generates the disclosure paragraph from what is actually enabled, under Settings › Tracking & Tags. It updates as the list changes, which is the point - the vendor list every SaaS maintains by hand is the list that goes stale.

Warning

You are the controller for these tags and the vendor is an independent controller; BuildBase is your processor acting on your instruction, and the toggle in the console is that instruction. The vendors are therefore your sub-processors and belong in your own disclosure, not ours.

Next

  • Providers - default category per vendor, and the CSP.
  • Events - what is sent once consent is given.