vevee.analytics.deletePerson() · getDeletionStatus()POST /api/v1/delete-personGET /api/v1/deletion-statussk_live_

GDPR Art. 17 - right to erasure. Permanently deletes everything APL holds for a user across analytics and metering. Requires a secret key (sk_*) - backend-only.

Signatures

vevee.analytics.deletePerson(
  distinctId: string,
  options?: {
    keepMetering?: boolean,   // SDK 1.10+, default false
    keepGuard?: boolean,      // SDK 1.15+, default false (implied by keepMetering)
  },
): Promise<{ jobId: string }>

vevee.analytics.getDeletionStatus(jobId: string): Promise<{
  status: 'queued' | 'in_progress' | 'completed' | 'failed';
  startedAt?: string;
  completedAt?: string;
  errorMessage?: string;
}>

What gets deleted

When the worker drains the job, the following are removed for that person:

  • All identified events (analytics_events).
  • The person profile and every distinct_id mapping pointing at it.
  • All metering data tied to the end_user_id: events, counters, subscriptions, subscription_events, reservations, event_logs, credit_balances, credit_ledger, media_assets rows.
  • Signup guard rows (guard_signups) under the id and every alias of it, unless keepGuard is set.

What stays

  • analytics_anonymous_events - no person link by design, no PII.
  • consent_audit_log entries - kept 5 years as legal evidence (consent_given, action and timestamp only, no profile data).
  • R2 object bytes for media - row removal makes them orphans; the existing R2 cleanup is what removes the bytes.

Keeping minimal metering (keepMetering)

By default everything is deleted. With { keepMetering: true } the person is still erased, but the bare metering stays under a random id, so your spend totals stay right and a paid store subscription cannot be used to reset its quota by deleting the account and restoring the purchase on a new one.

  • Deleted at once, always: analytics, aliases, prompts and responses, media, compose generations, and every metadata or free-text field on events, reservations and credits.
  • Kept: event type, quantity, cost, plan, limit-group ids and dates.
  • No active paid subscription: the kept rows move to a random anonymous id at once, dates are cut to the day and provider ids are cleared. Nothing links them back to the person.
  • Active paid subscription:the kept rows move to a random id that keeps the subscription's provider id and a keyed hash of the person's old ids, so transferSubscription() can move the subscription and its used quota to a new account. When the paid period ends (or the subscription moves away) the rows become anonymous as above.
await vevee.analytics.deletePerson(userId, { keepMetering: true });

After the deletion, ignore billing webhooks that still name the deleted user id: an upsert from that id naming the subscription would move it back to that id.

Keeping signup guard hashes (keepGuard)

By default the person's signup guard rows are deleted with everything else. That lets someone delete their account and sign up again for a fresh free plan. With { keepGuard: true } the rows stay, but unlinked:

  • Each row's user id is replaced with a random value, different for every row, so nothing links the rows back to the person or to each other.
  • The keyed hashes (email, device, network prefix) stay, so a new signup from the same email or browser still matches.
  • The stored email domain (kept only for public domains such as disposable services) is cleared on every kept row.
  • The rows keep their original expiry, 180 days after the signup. It is never extended.
  • keepMetering: true implies keepGuard. Old SDKs that do not send keepGuard get the default (rows deleted).
await vevee.analytics.deletePerson(userId, { keepGuard: true });

Name this in your privacy notice: the guide's Erasure and export section and the privacy policy templates have the wording.

Flow

// Backend route reacting to a "delete my data" form submission.
const { jobId } = await vevee.analytics.deletePerson('user_12345');

// Acknowledge the request immediately - the worker drains asynchronously.
return Response.json({ ok: true, deletionJobId: jobId });

Optional: surface progress to the user.

const status = await vevee.analytics.getDeletionStatus(jobId);
// → { status: 'in_progress', startedAt: '2026-05-23T08:00:00Z' }
i
Mid-erasure capture is silently dropped. Between deletePerson() and the worker finishing, the person row carries pending_deletion = 1, so further capture() calls for that id drop ahead of the worker - no new data is created mid-erasure.

Completion target

7 days target, 30 days maximum (GDPR Art. 12). Drain cadence is governed by your external trigger of /api/cron/analytics-deletion-worker - the cron endpoint is authenticated via CRON_SECRET and is meant to be called by an external worker (a GitHub Actions cron, an external scheduler, or your own server). It is not wired in vercel.json.

End-to-end backend example

// app/api/privacy/delete/route.ts
import { aplClient } from '@/lib/vevee';   // createClient({ apiKey: 'sk_live_…' })

export async function POST(req: Request) {
  const { userId } = await req.json();
  // Also delete from YOUR OWN database here. APL's erasure only covers APL.
  await db.user.deleteAccountData(userId);

  const { jobId } = await aplClient.analytics.deletePerson(userId);
  return Response.json({ ok: true, jobId });
}

// app/api/privacy/delete/status/route.ts
export async function GET(req: Request) {
  const jobId = new URL(req.url).searchParams.get('jobId')!;
  return Response.json(await aplClient.analytics.getDeletionStatus(jobId));
}

Errors

CodeStatusWhen
requires_secret_key403Called with a pk_* key.
not_found404getDeletionStatus called with an unknown jobId.
invalid_request400Empty or oversized distinctId / jobId.
i
Related: optOut() - non-destructive (reversible) right-to-object, exportPerson() - Art. 15 / 20, and the Privacy & GDPR guide.