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');
// Default scope 'all': captures are pseudonymized, compose() returns opted_out,
// prompt/response text is no longer stored. Existing data stays.
await vevee.analytics.optIn('user_12345'); // re-enables
// Or object to a single purpose:
await vevee.analytics.optOut('user_12345', 'analytics');
await vevee.analytics.optOut('user_12345', 'ai_personalization');
await vevee.analytics.optOut('user_12345', 'prompt_logging');The prompt_logging scope matters if you enabled Prompt logging for the app. When your legal basis for storing prompts is consent, the same call implements withdrawal (Art. 7(3)). After it, the prompt and response strings you pass to track(), reserve(), commit() and release() are never stored for that user, while metering and limits keep working. It is forward-only: logs stored earlier stay until their retention window ends or you call deletePerson(). Prompt logging is off by default for every app, so if you never turned it on there is nothing to switch off. Full details on the optOut() reference.
Right 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.