# 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.

```tsx
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.

```tsx
'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:

| Moment                                  | Data layer                                                  | On the page                                             |
| --------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- |
| Page load                               | `consent default` - all four denied, `wait_for_update: 500` | Consent Mode tags (GTM, GA4, Google Ads), cookies off   |
| `{ analytics: true, marketing: false }` | `consent update` - `analytics_storage` granted              | plus analytics tags without Consent Mode (Clarity, ...) |
| `{ analytics: true, marketing: true }`  | `consent update` - all four granted                         | plus 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:

```tsx
<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.

> **Note:**
  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:

```tsx
'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.


## Proof of consent

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.

```tsx
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:

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "$BUILDBASE_SERVER_URL/api/organizations/tracking/consent-records?email=person@example.com"
```

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:

```tsx
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.

> **Note:**
  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](#regional-defaults) instead.

```tsx
<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.


| Parameter | Kind | What it records |
| --- | --- | --- |
| `ref` | Referral | Who sent this visitor, and who gets credited |
| `via` | Referral | Affiliate partner who sent this visitor, in the affiliate tools' own param |
| `utm_source` | Campaign | Which property or channel the visit came from |
| `utm_medium` | Campaign | The format within that source |
| `utm_campaign` | Campaign | Which named piece of marketing this was |
| `utm_id` | Campaign | The campaign's id in the ad platform, for joining to spend |
| `utm_content` | Campaign | Which variation inside one campaign |
| `utm_term` | Campaign | The search term that was paid for |
| `utm_source_platform` | Campaign | The platform the campaign was managed in |
| `utm_creative_format` | Campaign | The creative's format |
| `utm_marketing_tactic` | Campaign | Who the campaign targeted |
| `gclid` | Ad click id | Google Ads click id |
| `gbraid` | Ad click id | Google Ads iOS click id, app to web |
| `wbraid` | Ad click id | Google Ads iOS click id, web to app |
| `dclid` | Ad click id | Google Display & Video 360 click id |
| `gad_source` | Campaign | Which Google surface the click came from |
| `fbclid` | Ad click id | Meta click id |
| `msclkid` | Ad click id | Microsoft Advertising click id |
| `li_fat_id` | Ad click id | LinkedIn click id |
| `twclid` | Ad click id | X click id |
| `ttclid` | Ad click id | TikTok click id |
| `rdt_cid` | Ad click id | Reddit click id |
| `sccid` | Ad click id | Snapchat click id |
| `epik` | Ad click id | Pinterest click id |
| `irclickid` | Ad click id | Impact 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 &amp; 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.

> **Note:**
  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](/tracking/providers) - default category per vendor, and the CSP.
- [Events](/tracking/events) - what is sent once consent is given.
