Skip to main content

The consent model

Consent is data, not a checkbox. This page shows how to store it, how to enforce it, and how to change it later without re-asking every user. Read it before you add any SDK, tracker, AI call or email that depends on a user saying yes.

How it works​

Three ideas carry the whole design:

  1. A purpose is a row, not an enum. Each thing a user can say yes or no to is its own purpose: analytics, personalisation, marketing_email, ai_processing, attribution, crash_reporting. Adding a purpose is an insert, not a migration.
  2. Every decision is a new event. You never update a consent row. A withdrawal is a new row with state = denied. The current state is a view over the events.
  3. Enforce where the data leaves. If an SDK on the phone sends the data, the phone is the gate. If our server sends the data (to an AI provider, an email service, a profiling job), the server is the gate.

A purpose is one reason we process data, explained to the user in one sentence. A notice is the exact text the user saw when they decided. The notice version says which text that was.

How to build it​

Tables​

-- Global DB: app content, same for everyone.
create table consent_purpose (
code text primary key, -- 'analytics', 'ai_processing'
default_state text not null check (default_state in ('granted','denied')),
requires_explicit_optin boolean not null,
is_user_visible boolean not null, -- shown in Settings, so it stays revocable
notice_surface text not null -- 'first_run' or 'ai_sheet' or 'email'
);

create table consent_notice (
surface text not null,
version text not null, -- 'YYYY-MM-DD', same id as the policy version
policy_url text not null, -- versioned URL, not the moving /privacy page
copy_hash text not null, -- sha256 of the exact text shown, server-computed
is_current boolean not null default false,
primary key (surface, version)
);
create unique index one_current_notice_per_surface
on consent_notice (surface) where is_current;

-- Regional DB: this is personal data, it lives in the user's home region.
create table consent_event (
id uuid primary key,
user_id uuid, -- null for a guest
device_id text not null, -- guests are data subjects too
purpose_code text not null,
state text not null check (state in ('granted','denied')),
notice_version text not null,
copy_hash text not null,
source text not null, -- 'first_run', 'settings', 'ai_sheet', 'unsubscribe_link'
decided_at timestamptz not null, -- when the USER acted, sent by the client
recorded_at timestamptz not null default now(),
ip_address inet,
user_agent text
);

create view consent_state as
select distinct on (coalesce(user_id::text, device_id), purpose_code)
coalesce(user_id::text, device_id) as subject_key,
purpose_code, state, notice_version, decided_at
from consent_event
order by coalesce(user_id::text, device_id), purpose_code,
decided_at desc, recorded_at desc;

Why decided_at and recorded_at are separate: a phone can be offline for a day. The decision the user made last must win, not the request that arrived last.

API shape​

GET /consent/registry -> purposes + current notice per surface (cacheable)
PUT /consent -> body: { deviceId, decisions: [
{ purpose, state, noticeVersion, decidedAt } ] }
appends one event per decision, idempotent on replay
GET /consent -> current state per purpose for this user or device

Validate every body (for example with Zod). Reject an unknown purpose code with an error that names the code in the log.

Client flow​

  1. Before any tracking SDK starts, read the stored decisions. No decision yet means collection stays off.
  2. Show the consent screen. Toggles start off. An "Allow all" button is fine. A pre-ticked box is not valid consent.
  3. On a decision: write local storage, apply to the SDKs, then push to the API.
  4. On sign-in: push the full local state with the user token, then pull the server state and apply whichever decision is newest. Never re-ask a guest who becomes a user.
  5. On withdrawal: switch the SDK off, reset its analytics ID so the old pseudonymous ID is not reused, and append a denied event.
  6. On a new notice version for a surface: re-ask only the purposes on that surface, with the old choices pre-filled.

Separate surfaces, separate versions​

Keep one notice version per surface. The first-run screen (analytics, personalisation, crash reports) has its own version. The AI sheet has its own. Email has its own. Then a change to the AI wording re-asks people only the next time they open an AI feature. It does not re-prompt the whole installed base about analytics.

Personalisation and other server-side purposes​

Some purposes are enforced on the server, because the processing happens there:

  • Do not write profile events for a user who said no. Filtering at read time is too late: the profiling already happened.
  • Skip those users in any batch job that builds interest profiles.
  • On withdrawal, delete the derived profile. Ignoring it is not enough.

Rules​

Rule

Consent must gate the SDK before it initialises. Read the stored decision first, then start the SDK. An analytics SDK switched on at app boot collects data before the user has seen the question, and that data cannot be un-collected.

Rule

The client and the server must know the same purposes. Seed a new purpose on the server in every environment before the app that sends it ships. An unknown purpose rejected by the server means the user tapped "Allow" and nothing was recorded.

Rule

Never edit consent copy without a new notice version. If screen text can change over the air (remote translation bundles, CMS copy), exclude consent strings from it, or bind the notice version to a hash of the copy. Otherwise a translator can change what users agreed to.

Rule

The copy must be literally true. "We never share your messages" is false if a hash of the message reaches an analytics tool. Hashed still means shared. Send safe proxies instead, such as a character count.

Rule

Consent records are evidence. Do not delete them when the account is deleted. Anonymise the user link and keep them for the retention period your DPO sets. Without them you cannot prove that past processing was lawful.

Mistakes we made​

  • Analytics started at boot for everyone. One line enabled the analytics SDK before any screen existed, for guests too. A guest still has a device ID, IP and event stream, which is personal data. Gate the SDK on the stored decision, and treat guests as in scope.
  • A whole purpose was silently discarded. The app added an ai_processing purpose after a store rejection. The server registry never got the row, so every "Allow" returned a 400 and nothing was written: hundreds of lost decisions a day, for the exact consent the store had asked for. The error log dropped the offending code, so finding it took a code read. Seed the server first, keep the two registries in sync with a test, and log the value that failed validation.
  • One fix nearly re-prompted every user. The obvious fix was to add AI to the first-run notice. That would have minted a new notice version and re-asked the whole installed base on a screen that must not ask about AI. Use one notice version per surface.
  • "Excluded from over-the-air copy" was assumed, never built. The plan said consent strings were locked. The loader merged remote copy over bundled copy with no filter. Test the mechanism, not the plan.
  • A client-sent copy hash. A hash the client sends proves nothing; it can send any value. The server owns the consent copy, renders it, and computes the hash.
  • Two version ids for one idea. A config value stamped 1.0 on sign-up consent rows, while the notice table used dates. Pick one namespace.
  • Calling the API does not stop the SDK. Some analytics tools have no server-side consent switch. The device is the only gate for them. The server record is for audit and for our own processing.

Checklist​

  • The new purpose exists in the server registry in every environment before the app ships.
  • The default is denied unless the DPO has signed off another lawful basis.
  • The SDK or server call is gated on the stored state, and stays off with no decision.
  • Withdrawal is as easy as granting: one switch in Settings.
  • Withdrawal stops the processing at once and resets or deletes derived data.
  • Consent copy is versioned, hashed on the server and not editable over the air.
  • The copy matches what the code actually sends.
  • Store privacy labels and the privacy policy describe the same purpose.
  • Rejections log the purpose code that failed.