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 individual pre-signup journeys and you already run a CMP. For where signups come from, you do not need it: hybrid mode supports cookieless first-touch attribution.
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).
Cookieless attribution
Traffic attribution (SDK 1.14.0+) tells you which channel brought each signup and each order without a banner. It stores nothing on the device and links no anonymous visit to a person. This section lists what is kept, where and for how long, so you can describe it in your privacy notice.
What is stored, where, for how long
| What | Where | How long | Linked to a person? |
|---|---|---|---|
| Visit id, start time, UTM tags, referrer host, landing path | Browser memory only (a JavaScript variable) | Until reload or tab close | No |
| Anonymous events with UTM tags, referrer host, landing path, channel, country, region and city (only if your server sends them), device type, browser, OS | Vevee anonymous events | Visit-scoped pageviews: 90 days. Other anonymous events: your standard analytics retention. | No |
| Hash of the visit id with a per-day salt | Vevee anonymous events | The salt is deleted within about 48 hours, after which the hash cannot be recomputed | No |
| Daily counts by channel, campaign, referrer, landing page, country, device | Vevee rollups | Aggregates, kept for reporting | No |
First touch: UTM tags, referrer host, landing path, channel, day, country, device class, browser, OS (mirrored as first_* properties) | Vevee person record | Until the person is erased | Yes, set once at signup |
| Orders and refunds: order and refund ids, amounts, currency, billing reason, product, discount, date | Vevee person record | Until the person is erased | Yes |
First touches, orders and refunds are included in exportPerson() and deleted by deletePerson().
What is never stored
- The IP address. Geo comes from your host's headers through
context, never from the IP. - The raw user agent: only device type, browser and OS are kept.
- The raw visit id. It is dropped before
identify()stores a first touch, and anonymous events keep only the salted hash. - Ad click identifiers (
fbclid,gclid,msclkid,ttclidand others): removed from properties and from URL values. - Query strings of landing paths, and full referrer URLs (only the host is kept).
- Region and city on a first touch.
- Any cookie or device storage for analytics.
Rules that keep the halves apart
- Identified events carry no visit fields, and the server drops a
visit_idfrom them. - Only the source crosses over, copied once into the first touch.
- Opted-out people get no first touch; their events and orders are recorded under a pseudonym.
- People pending erasure are dropped: nothing is written for them.
This is not a promise of unlinkability. A signup has an exact time, so on a very quiet day timing alone could match a signup to a visit. Vevee accepts that cost to give you per-person attribution, and the DPA says the same.
A sentence your privacy notice can adapt: “Where you came from is kept only in the page's memory while you browse: no cookie or other storage is used, your IP address is not stored, and ad click identifiers (such as fbclid or gclid) are removed.”
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
Attributes are 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.
- Cookieless traffic attribution.
- Attributes - declared semantic facts; erasure and opt-out behaviour described above.
- DPA · Sub-processors · LIA template · Privacy-policy templates.