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 setupCookie banner required?
hybrid mode (default), no mergeNo.
hybrid mode + identify({ mergeAnonymousId, consentGiven: true })Yes, for the merge.
identified modeYes.
aggregate modeNo.

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 no distinctId. Events land in analytics_anonymous_events with 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 in analytics_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.

i
Mode is fixed at client construction. For per-user opt-outs within 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, no sessionStorage, no IndexedDB.
  • 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:

  1. Acquire explicit consentthrough your cookie banner BEFORE the merge - typically a checkbox like “Improve my experience by linking my previous visit”.
  2. 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

WhatWhereHow longLinked to a person?
Visit id, start time, UTM tags, referrer host, landing pathBrowser memory only (a JavaScript variable)Until reload or tab closeNo
Anonymous events with UTM tags, referrer host, landing path, channel, country, region and city (only if your server sends them), device type, browser, OSVevee anonymous eventsVisit-scoped pageviews: 90 days. Other anonymous events: your standard analytics retention.No
Hash of the visit id with a per-day saltVevee anonymous eventsThe salt is deleted within about 48 hours, after which the hash cannot be recomputedNo
Daily counts by channel, campaign, referrer, landing page, country, deviceVevee rollupsAggregates, kept for reportingNo
First touch: UTM tags, referrer host, landing path, channel, day, country, device class, browser, OS (mirrored as first_* properties)Vevee person recordUntil the person is erasedYes, set once at signup
Orders and refunds: order and refund ids, amounts, currency, billing reason, product, discount, dateVevee person recordUntil the person is erasedYes

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, ttclid and 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_id from 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.”

!
Not legal advice. Because nothing is stored on or read from the device for analytics, the ePrivacy device-storage rule does not ask for consent, and one customer, a study app, relies on legitimate interest (GDPR Art. 6(1)(f)) with data minimisation for the rest. That is their own reading. Check your setup, your other tools and your audience (for example minors) with your own counsel.

What you must do (even without a banner)

  1. 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.
  2. Provide an opt-out in your app settings. When the user toggles it, call optOut(distinctId) from your backend.
  3. Honor data-subject requests - wire deletePerson() and exportPerson() to your DSR workflow.
  4. Sign the DPA before going to production - published at /dpa.
  5. 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 an attributes block in the returned JSON, one entry per set attribute with its value, setAt timestamp, and source (auto / sdk_direct / dashboard / api / import).
  • Art. 17 (erasure). deletePerson() deletes attribute values immediately. The attribute_audit_log rows 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: true in 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