Privacy & GDPR
How to integrate APL Analytics so it’s compliant in the EU without a cookie banner by default, and what to do when you do need one. This is a practical guide for the developer integrating APL. The legal artefacts (DPA, sub-processors, LIA template, privacy-policy templates) are published on the marketing site.
TL;DR
| Your setup | Cookie banner required? |
|---|---|
hybrid mode (default), no merge | No. |
hybrid mode + identify({ mergeAnonymousId, consentGiven: true }) | Yes, for the merge. |
identified mode | Yes. |
aggregate mode | No. |
This assumes APL is your only third-party tool. Google Analytics, Meta Pixel, Hotjar, Intercom with persistent identifiers, etc. independently require a banner - APL’s compliance posture doesn’t extend to them.
Tracking modes - when to use each
Set once at createClient:
const vevee = createClient({
apiKey: process.env['VEVEE_KEY']!,
analytics: {
mode: 'hybrid', // 'hybrid' (default) | 'identified' | 'aggregate'
requireConsent: true, // default
},
});hybrid - recommended for EU
- Pre-login visitors - call
capture({ event })with nodistinctId. Events land inanalytics_anonymous_eventswith no person profile, no stored IP, no browser identifier. A salted, 24h-scoped hash dedupes visitors within one day. - Post-login users - pass your real user id as
distinctId. Events are linked to a person profile inanalytics_events. - What you measure: aggregate funnels, conversion rates, drop-off (pre-login); individual journeys, retention, feature adoption (post-login).
- What you can’t measure by default:linking an anonymous pre-login session to the user who signed up later. That’s the consent-gated merge - see below.
identified
Every visitor gets a persistent anonymous id via getAnonymousId() → localStorage. Individual journeys can be tracked from first touch through signup. Requires a cookie banner in the EU.
Use when you need first-touch attribution and you already run a CMP.
aggregate
Everything anonymous. identify() and alias() are disabled. capture() silently drops any distinctId you pass.
Use for compliance-strict contexts where top-line metrics are enough.
hybrid, use optOut().Why hybrid mode doesn’t need a banner
EU ePrivacy Directive Art. 5(3) and the Italian Garante’s Cookie Guidelines (Provv. 231/2021) require consent before storing or accessing information on the user’s device - but only for storage that is not strictly necessary for the service.
In hybrid mode, APL:
- Writes no cookies, no
localStorage, nosessionStorage, noIndexedDB. - Aggregates pre-login events without individual tracking - the daily session hash is salted and rotates every 24h, so cross-day correlation is impossible by design.
- Tracks identified users only after they’ve signed up, where the basis is legitimate interest in product improvement (GDPR Art. 6.1.f), with a mandatory opt-out.
This pattern falls within “technical or statistical purposes” (Garante FAQ 6) and the legitimate-interest basis when implemented with proper data minimisation.
You still need to disclose APL in your privacy policy and provide an opt-out - see What you must do below.
Anonymous → identified (the merge)
If at signup you want to link the visitor’s pre-signup browsing session to the new account, you must:
- Acquire explicit consentthrough your cookie banner BEFORE the merge - typically a checkbox like “Improve my experience by linking my previous visit”.
- Call
identify()with the merge and the declaration:
await vevee.analytics.identify(
user.id,
{ email },
undefined,
{ mergeAnonymousId: anonId, consentGiven: true },
);APL records the consent in consent_audit_log as a 5-year responsibility-transfer record. Without consentGiven: true the SDK throws consent_required (400). The same gate applies to alias() when either id is an APL anonymous session (anon_ prefix).
What you must do (even without a banner)
- Update your privacy policy to disclose APL as a processor and the legal basis for processing. Ready-made IT / EN sections are at /privacy-policy-templates.
- Provide an opt-out in your app settings. When the user toggles it, call
optOut(distinctId)from your backend. - Honor data-subject requests - wire
deletePerson()andexportPerson()to your DSR workflow. - Sign the DPA before going to production - published at /dpa.
- Maintain a Legitimate Interest Assessment (LIA) for the analytics processing. Template at /lia-template.
GDPR rights - implementation guide
Map each right to an APL API:
Right to object (Art. 21) - optOut
await vevee.analytics.optOut('user_12345');
// Future captures for this id silently drop. Existing data stays.
await vevee.analytics.optIn('user_12345'); // re-enablesRight to erasure (Art. 17) - deletePerson
const { jobId } = await vevee.analytics.deletePerson('user_12345');
const status = await vevee.analytics.getDeletionStatus(jobId);
// Cascades across analytics + metering. Completion target 7 days, max 30.Right of access (Art. 15) & portability (Art. 20) - exportPerson
const { downloadUrl, expiresAt } = await vevee.analytics.exportPerson('user_12345');
// Email the URL to the data subject. Valid 24h. HMAC-signed bearer URL.Combine with your own database export to satisfy the full request.
Right to rectification (Art. 16) - identify
Person profile properties are mutable via identify(distinctId, properties). Historical events are immutable by design; if a user disputes a specific event’s accuracy, the remedy is deletePerson().
Attributes
Attributesare Customer-declared semantic facts on a person — typed, named values like persona or biggest_problem. They follow the same privacy guarantees as the rest of the analytics surface:
- Art. 15 / 20 (access / portability).
exportPerson()includes anattributesblock in the returned JSON, one entry per set attribute with its value,setAttimestamp, andsource(auto / sdk_direct / dashboard / api / import). - Art. 17 (erasure).
deletePerson()deletes attribute values immediately. Theattribute_audit_logrows for this person are kept for legal evidence but their values are scrubbed in place to[REDACTED]— no PII remains in the audit row, but the row itself stays. - Art. 21 (objection). Opted-out persons receive no further attribute writes; auto-promotion from events is silently skipped.
- Sensitive attributes. Declare an attribute with
isSensitive: truein the dashboard and audit-log entries store a hash of the value rather than the raw value. Future iterations will extend this masking to the dashboard reveal-on-click flow.
Settings page - drop-in example
A React component your customers can paste into their app:
'use client';
import { useState, useEffect } from 'react';
export function PrivacySettings({ userId }: { userId: string }) {
const [enabled, setEnabled] = useState(true);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch(`/api/privacy/status?userId=${userId}`)
.then(r => r.json())
.then(({ optedOut }) => setEnabled(!optedOut));
}, [userId]);
async function toggle(next: boolean) {
setLoading(true);
try {
await fetch('/api/privacy/toggle', {
method: 'POST',
body: JSON.stringify({ userId, enable: next }),
});
setEnabled(next);
} finally { setLoading(false); }
}
async function deleteData() {
if (!confirm('Permanently delete your analytics data? This cannot be undone.')) return;
const r = await fetch('/api/privacy/delete', {
method: 'POST', body: JSON.stringify({ userId }),
}).then(r => r.json());
alert(`Deletion requested (job ${r.jobId}). Completion within 30 days.`);
}
async function downloadData() {
const r = await fetch('/api/privacy/export', {
method: 'POST', body: JSON.stringify({ userId }),
}).then(r => r.json());
window.location.href = r.downloadUrl;
}
return (
<section>
<h2>Privacy</h2>
<label>
<input type="checkbox" checked={enabled} disabled={loading}
onChange={(e) => toggle(e.target.checked)} />
Enable product analytics (helps us improve)
</label>
<button onClick={downloadData}>Download my data</button>
<button onClick={deleteData}>Delete all my data</button>
</section>
);
}The corresponding backend routes use the secret key - never embed in client code:
// app/api/privacy/toggle/route.ts
import { aplClient } from '@/lib/vevee'; // createClient({ apiKey: 'sk_live_…' })
export async function POST(req: Request) {
const { userId, enable } = await req.json();
if (enable) await aplClient.analytics.optIn(userId);
else await aplClient.analytics.optOut(userId);
return Response.json({ ok: true });
}
// app/api/privacy/delete/route.ts
export async function POST(req: Request) {
const { userId } = await req.json();
const { jobId } = await aplClient.analytics.deletePerson(userId);
return Response.json({ jobId });
}
// app/api/privacy/export/route.ts
export async function POST(req: Request) {
const { userId } = await req.json();
const { downloadUrl, expiresAt } = await aplClient.analytics.exportPerson(userId);
return Response.json({ downloadUrl, expiresAt });
}CMP integration
APL works alongside any consent management platform. Pattern is the same regardless of provider (Cookiebot, Iubenda, OneTrust, Usercentrics, Didomi):
cmp.onConsentChange((consents) => {
fetch('/api/privacy/toggle', {
method: 'POST',
body: JSON.stringify({ userId: currentUserId, enable: consents.analytics === true }),
});
});Italian Garante - specific notes
The Italian Data Protection Authority is among the strictest enforcers. Specific guidance from recent provvedimenti, applicable when you do show a banner (identified mode, or for the merge in hybrid mode):
- Banners must offer “Reject all” with equal prominence to “Accept all” (Provv. 231/2021).
- Scrolling, X-close or continued navigation cannot be interpreted as consent.
- Banners reappearing on every visit after rejection are considered coercive (Provv. 4/6/2025, doc.web 10152729).
- Pre-checked consent boxes are prohibited (Provv. 27/2/2025, doc.web 10118222).
Where to next
- capture() - record events.
- identify() - consent-gated merge.
- optOut() / optIn() / isOptedOut().
- deletePerson() / getDeletionStatus().
- exportPerson().
- Behavioral analytics & funnels.
- Attributes - declared semantic facts; erasure and opt-out behaviour described above.
- DPA · Sub-processors · LIA template · Privacy-policy templates.