> For the complete documentation index, see [llms.txt](https://docs.millimetric.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.millimetric.ai/recipes/gdpr-delete.md).

# GDPR right-to-be-forgotten

A user emails support: "delete all my data". This recipe walks through running that request through Millimetric end-to-end, what's actually deleted, and the audit trail you should keep on your side.

## TL;DR

```bash
curl -X POST https://api.millimetric.ai/v1/forget \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "user_42" }'
```

`sk_*` only. `pk_*` returns `403 forget_requires_secret_key` — a leaked browser key cannot wipe data.

## Step 1 — verify the request

You should always have *your* identity check between the email and the delete. The Worker doesn't authenticate the end user; it authenticates *you* via the `sk_*` key.

```ts
async function forgetUser(userId: string, requestedBy: string) {
  // 1. Confirm the requester is who they say they are (your auth).
  // 2. Log the request in your own audit table.
  await db.insert("forget_requests", {
    user_id: userId,
    requested_by: requestedBy,
    requested_at: new Date()
  });
  // 3. Then, and only then, call Millimetric.
}
```

## Step 2 — call /v1/forget

```ts
const res = await fetch(`${process.env.AOA_HOST}/v1/forget`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AOA_SK}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ user_id: userId })
});

if (!res.ok) {
  const err = await res.json();
  throw new Error(`forget failed: ${err.error}`);
}

return res.json();   // { ok: true, queued: true }
```

The mutation is **queued** on ClickHouse — `ALTER TABLE events DELETE WHERE project_id = ? AND user_id = ?`. Typical completion: seconds. For very large tables: minutes.

## Step 3 — also delete from related systems

`/v1/forget` only handles Millimetric. Don't forget:

* Your application database (the `users` row, sessions, content).
* Stripe / billing provider (use their `Customer.delete`).
* Email provider (Resend / Sendgrid suppression list).
* Backups, if you're being thorough — usually documented in your privacy policy as "within X days".

## What gets deleted in Millimetric

* Every row in `events` where `project_id = <your project>` AND `user_id = <user>`.

## What does **not** get deleted

| Thing                                            | Why                                                                                                                                                                         |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Anonymous events from before identify            | They have `user_id = NULL` and are indistinguishable from any other anonymous traffic. By design — the system can't tell which anonymous events belong to a now-known user. |
| Aggregated rows in `daily_rollup` and `sessions` | Aggregates, not personal data. They never had PII. Re-aggregate from raw if you want to strip a user's contribution.                                                        |
| `ip_hash` rows                                   | Already irreversible. The salt rotates daily, so within a day they're per-user, but cross-day linkability is gone.                                                          |
| Events under other projects                      | Scoped to the project owning the `sk_*` key.                                                                                                                                |

## If you also need to forget anonymous events

`/v1/forget` doesn't have `anonymous_id` support yet (PR welcome). Run the SQL directly against ClickHouse:

```sql
ALTER TABLE events DELETE
  WHERE project_id = '{your-project-id}'
    AND anonymous_id = '{the-anonymous-id-the-user-told-you}';
```

Get the `anonymous_id` from the user themselves (developer-tools snippet you ship them) or by stitching from `user_id` first:

```sql
SELECT DISTINCT anonymous_id
FROM events
WHERE project_id = '...' AND user_id = 'user_42';
```

Run forget by `user_id` first (kills post-identify events), then run the anonymous-id deletes for each one returned.

## Audit trail — the part Millimetric doesn't do for you

Today the API doesn't yet write a structured audit row for `/v1/forget` calls. **Until that's built, you keep your own audit log on the calling side**:

```sql
CREATE TABLE forget_requests (
  id            uuid primary key default gen_random_uuid(),
  user_id       text not null,
  requested_by  text not null,
  requested_at  timestamptz not null,
  millimetric_response jsonb,
  related_systems jsonb default '[]'::jsonb
);
```

Log:

* Who made the request.
* Who approved it.
* Timestamp.
* The exact response from `/v1/forget` (so you have proof of the queued mutation).
* Other systems you also wiped (Stripe customer id, etc.).

Keep this audit log indefinitely — it's *your* compliance evidence.

## Worked example

```ts
import { z } from "zod";

const Body = z.object({ user_id: z.string().min(1) });

export async function POST(req: Request) {
  // 1. Authenticate the operator. Probably an admin role on your side.
  const session = await requireAdmin(req);

  // 2. Validate input.
  const { user_id } = Body.parse(await req.json());

  // 3. Audit before action.
  const audit = await db.insert("forget_requests", {
    user_id,
    requested_by: session.userId,
    requested_at: new Date()
  });

  // 4. Millimetric.
  const mmRes = await fetch(`${process.env.AOA_HOST}/v1/forget`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AOA_SK}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ user_id })
  });
  const mmJson = await mmRes.json();
  await db.update("forget_requests", { id: audit.id }, { millimetric_response: mmJson });

  if (!mmRes.ok) return Response.json(mmJson, { status: 500 });

  // 5. Other systems.
  await stripe.customers.del(stripeCustomerIdFor(user_id));
  await db.delete("users").where("id", user_id);

  return Response.json({ ok: true });
}
```

## Errors

| Status | `error`                      | Meaning                                                              |
| ------ | ---------------------------- | -------------------------------------------------------------------- |
| 401    | `invalid_api_key`            | `sk_*` revoked or wrong project.                                     |
| 403    | `forget_requires_secret_key` | You sent a `pk_*`.                                                   |
| 400    | `invalid_payload`            | `user_id` missing or empty.                                          |
| 500    | `forget_failed`              | ClickHouse rejected the mutation. Worker logs have the trace. Retry. |

## See also

* [Privacy & retention](/core-concepts/privacy.md) — what's stored in the first place.
* [POST /v1/forget](/api-reference/forget.md) — endpoint reference.
* [Identities](/core-concepts/identities.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.millimetric.ai/recipes/gdpr-delete.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
