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 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.

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).

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');
// Future captures for this id silently drop. Existing data stays.
await vevee.analytics.optIn('user_12345');  // re-enables

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 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