# Handle a deletion request

> Delete or export everything Patterna holds about one user, and prove when it happened.

Source: https://staging.patterna.nl/docs/handle-a-deletion-request

When a user deletes their account or asks for their data, your backend makes one call. Patterna knows the user only by the random id you minted, so you look that id up on your side first.

## Setup for the code examples

Every example below comes in four tabs. The TypeScript and Go tabs use the SDKs for serve and ingest calls. The SDKs have admin methods too (`applyFlow`, `setSurfaceStatus`, `ledger` and so on), but these tabs call the admin REST API through the small `rest` helper defined here so every request is visible. The Python tab calls the REST API with httpx; the Python SDK (`patterna-sdk`, not on PyPI yet: install it from the public repository payam-h/patterna-sdk) has the same shape as the other two. The Go snippets run inside a function that has `ctx context.Context` and `pc *patterna.Client` from `newClient()` and returns an `error`; add `time` to the imports where a snippet uses it.

```curl
# Keys come from Connectors or Governance. Each is shown once.
# Serve keys start with pk_s_, ingest keys with pk_i_, admin keys with pk_a_.
export PATTERNA_SERVE_KEY="pk_s_paste-the-serve-key"
export PATTERNA_INGEST_KEY="pk_i_paste-the-ingest-key"
export PATTERNA_ADMIN_KEY="pk_a_paste-the-admin-key"
```

```ts
import { PatternaClient } from "patterna-sdk";

export const API_BASE = "https://api.patterna.nl/v1";

// Serve and ingest calls, plus user mode, profile and deletion, go through the SDK.
export const patterna = new PatternaClient({
  baseUrl: API_BASE,
  keys: {
    serve: process.env.PATTERNA_SERVE_KEY,
    ingest: process.env.PATTERNA_INGEST_KEY,
    admin: process.env.PATTERNA_ADMIN_KEY,
  },
});

// Endpoints the SDK leaves out (admin, proof, catalog reads) use plain fetch.
export async function rest<T = unknown>(
  keyEnv: string,
  method: string,
  path: string,
  body?: unknown,
): Promise<T | undefined> {
  const res = await fetch(`${API_BASE}${path}`, {
    method,
    headers: {
      authorization: `Bearer ${process.env[keyEnv]}`,
      ...(body === undefined ? {} : { "content-type": "application/json" }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await res.text();
  if (!res.ok) {
    throw new Error(`Patterna ${method} ${path}: ${res.status} ${text} (request id ${res.headers.get("x-request-id")})`);
  }
  return text ? (JSON.parse(text) as T) : undefined;
}
```

```go
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"

	patterna "github.com/payam-h/patterna-sdk/go"
)

const apiBase = "https://api.patterna.nl/v1"

// newClient builds the SDK client: serve and ingest calls, plus user mode, profile and deletion.
func newClient() (*patterna.Client, error) {
	return patterna.New(patterna.Config{
		BaseURL:   apiBase,
		ServeKey:  os.Getenv("PATTERNA_SERVE_KEY"),
		IngestKey: os.Getenv("PATTERNA_INGEST_KEY"),
		AdminKey:  os.Getenv("PATTERNA_ADMIN_KEY"),
		Logger:    slog.Default(),
	})
}

// rest calls the endpoints the SDK leaves out (admin, proof, catalog reads).
func rest(ctx context.Context, keyEnv, method, path string, body, out any) error {
	var rd io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rd = bytes.NewReader(b)
	}
	req, err := http.NewRequestWithContext(ctx, method, apiBase+path, rd)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv(keyEnv))
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		msg, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("patterna %s %s: %d %s (request id %s)", method, path, resp.StatusCode, msg, resp.Header.Get("X-Request-Id"))
	}
	if out == nil || resp.StatusCode == http.StatusNoContent {
		return nil
	}
	return json.NewDecoder(resp.Body).Decode(out)
}
```

```python
import os

import httpx

API_BASE = "https://api.patterna.nl/v1"


def client(key_env: str) -> httpx.Client:
    return httpx.Client(
        base_url=API_BASE,
        headers={"Authorization": f"Bearer {os.environ[key_env]}"},
        timeout=5.0,  # ingest and admin budget; recommend passes its own 150 ms
    )


serve = client("PATTERNA_SERVE_KEY")
ingest = client("PATTERNA_INGEST_KEY")
admin = client("PATTERNA_ADMIN_KEY")
```

## Delete a user

Deletion takes the ingest key, the same key your backend already holds. An admin key works too.

```curl
curl -sS -X DELETE "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY"
```

```ts
const deletion = await patterna.deleteUser("u_mfrggzdfmztwq2lknnwg23tpobyxe");
console.log(deletion.id, deletion.rows_deleted_at); // rows are gone; models_retrained_at is set after the nightly retrain
```

```go
del, err := pc.DeleteUser(ctx, "u_mfrggzdfmztwq2lknnwg23tpobyxe")
if err != nil {
	return err
}
fmt.Println(del.ID, del.RowsDeletedAt) // rows are gone; ModelsRetrainedAt is set after the nightly retrain
```

```python
r = ingest.delete("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe")
r.raise_for_status()  # 202
deletion = r.json()
print(deletion["id"], deletion["rows_deleted_at"])
```

The answer is `202 Accepted` with the deletion request. The rows are already gone when it arrives:

```json
{
  "id": "6f1c2b9e-3d4a-4f0e-9a51-2c7d8e1b0a44",
  "user_id": "u_mfrggzdfmztwq2lknnwg23tpobyxe",
  "requested_at": "2026-10-13T09:12:00Z",
  "rows_deleted_at": "2026-10-13T09:12:00Z",
  "models_retrained_at": null,
  "stored_as": "sha256"
}
```

`stored_as: "sha256"`: the deletion record keeps a salted hash of the id, not the id, so the record of a deletion is not itself personal data.

The SDKs wrap this call: `deleteUser(userId)` in TypeScript, `DeleteUser(ctx, id)` in Go.

## What happens next

Deletion is synchronous. In one transaction, before the answer, the engine:

- deletes the user row, the holdout assignments, the opt-out marker, the user's events and precomputed shortlist;
- unlinks the user's impressions: the user id is removed and the logged candidate list emptied. The slates stay in the ledger under their `unit_hash` (a SHA-256 of a secret salt Patterna holds per tenant and the user id; the salt is set once and never rotated, so the hash cannot be reversed and someone who holds a user id cannot check whether a row belongs to it), so ledger counts and intervals do not change after the fact.

`rows_deleted_at` is therefore set in the answer. One timestamp is left:

- **`models_retrained_at`.** Model artifacts learned from the user's data are cleared through the retrain schedule. The nightly learning run (02:00 UTC) stamps this field on every open request once it has retrained without the deleted rows.

**Governance → deletion requests** shows every request with both timestamps. Audiences → trace lookup shows the status for a single id.

## Export a user's data

```curl
curl -sS "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
```

```ts
const data = await rest("PATTERNA_ADMIN_KEY", "GET", "/users/u_mfrggzdfmztwq2lknnwg23tpobyxe");
console.log(JSON.stringify(data, null, 2));
```

```go
var export map[string]any
if err := rest(ctx, "PATTERNA_ADMIN_KEY", "GET", "/users/u_mfrggzdfmztwq2lknnwg23tpobyxe", nil, &export); err != nil {
	return err
}
b, _ := json.MarshalIndent(export, "", "  ")
fmt.Println(string(b))
```

```python
r = admin.get("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe")
r.raise_for_status()
print(r.json())
```

The answer is every row Patterna holds about that id, as JSON. Hand it to the user together with your own export.

## Consent revoked: an opt-out, not a deletion

When a user withdraws consent for personalized recommendations but keeps the account, set their mode to `non_profiled`:

```curl
curl -sS -X PUT "https://api.patterna.nl/v1/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/mode" \
  -H "Authorization: Bearer $PATTERNA_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "non_profiled" }'
```

```ts
// The SDK sends the admin key when configured, else the ingest key.
await patterna.setUserMode("u_mfrggzdfmztwq2lknnwg23tpobyxe", "non_profiled");
```

```go
// The SDK sends the admin key when configured, else the ingest key.
if err := pc.SetUserMode(ctx, "u_mfrggzdfmztwq2lknnwg23tpobyxe", patterna.ModeNonProfiled); err != nil {
	return err
}
```

```python
r = ingest.put("/users/u_mfrggzdfmztwq2lknnwg23tpobyxe/mode", json={"mode": "non_profiled"})
r.raise_for_status()
```

Switching to `non_profiled` is an opt-out, with the same semantics as a deletion of the profile. In the request the user row (profile and affinities) is deleted, Patterna stores a salted hash of the id as the opt-out marker, and it files a deletion request that holds only that salted hash. The hourly `deletions` job then unlinks the user's impressions (the id and the logged candidates are cleared; the salted `unit_hash` stays, so ledger rows do not change after the fact), keeps their interactions (they belong to the slate), and deletes the user's events, holdout assignments and precomputed shortlist. Serve honours the marker at once: a `personalized` or `cohort` request for that id is served as `non_profiled` and logged without the id, keeping the unit it had and, unless the holdout is stratified by persona (the user has no persona any more), the arm; the audit log records `serve.optout_downgrade` once per day, without the id. Setting the mode to `personalized` or `cohort` again (with this call, or a profile that carries `mode`) clears the marker. The nightly retrain clears the models.

| | Opt-out (`PUT /users/{id}/mode` `non_profiled`) | Deletion (`DELETE /users/{id}`) |
|---|---|---|
| User row and profile | deleted in the request | deleted in the request |
| Events, holdout assignments, shortlist | deleted by the hourly `deletions` job | deleted in the request |
| Impressions | kept, unlinked by the hourly job | kept, unlinked in the request |
| Interactions | kept with their slate | kept with their slate |
| Serving | at once: profiled requests for the id are served as `non_profiled` | the id is unknown; a new profile needs `mode` |
| What Patterna keeps about the id | a salted hash, as the opt-out marker serve honours | a salted hash in the deletion record; the opt-out marker is removed |
| Models | forget at the next nightly retrain | forget at the next nightly retrain (`models_retrained_at`) |

From then on the user is served on a rotating session id only. See [Consent modes](https://staging.patterna.nl/docs/consent-modes).

## The pattern on your side

This pattern keeps deletion reliable without keeping personal data:

- **Keep the mapping on your side.** A table maps your account id to the random Patterna id. Patterna never sees your account id or an email.
- **Tell Patterna first, then delete the mapping.** Look up the Patterna id, call delete, then remove the account and its mapping row.
- **Never block the user's deletion.** If the call fails, store only the random id in a retry table and delete the account anyway. Once the mapping row is gone the random id points to nobody.
- **Retry until confirmed.** A loop (every 10 minutes is plenty) resends pending deletions and removes each id once Patterna answers.
- **Rotate on revoke.** After a successful switch to `non_profiled`, delete the mapping row. A later opt-in mints a fresh id that cannot be joined to the old history.
- **One path for every deletion.** Staff tools and retention jobs that delete users call the same erase function.

## Which key

| Call | Key |
|---|---|
| `DELETE /users/{user_id}` | ingest (`pk_i_…`) or admin |
| `PUT /users/{user_id}/mode`, `PUT /users/{user_id}/profile` | ingest or admin |
| `GET /users/{user_id}` (export), `GET /users/{user_id}/trace` | admin (`pk_a_…`) |

A customer backend needs no admin key for consent and deletion. Both SDKs send the admin key when one is configured and the ingest key otherwise; configure only the ingest key on a backend.
