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_idmapping 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_assetsrows. - 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_logentries - 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: trueimplieskeepGuard. Old SDKs that do not sendkeepGuardget 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' }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
| Code | Status | When |
|---|---|---|
requires_secret_key | 403 | Called with a pk_* key. |
not_found | 404 | getDeletionStatus called with an unknown jobId. |
invalid_request | 400 | Empty or oversized distinctId / jobId. |