GDPR — operator-only data-subject flow
Helios has **no user-facing opt-out button**. The only privacy product is
Helios has no user-facing opt-out button. The only privacy product is paid Stealth Mode (CLAUDE.md §6.6). Regulatory data-subject erasure requests — "right to be forgotten" — are handled out-of-band by the operator through the API.
The
${PUBLIC_BASE_URL}placeholder in the curl examples below refers to the public HTTPS origin you configured in.env(PUBLIC_BASE_URL). Export the variable in your shell before running the examples:export PUBLIC_BASE_URL=https://your-domain.tld.
Endpoints
Both behind require_owner (which itself enforces TOTP when enabled —
see TOTP enrolment).
POST /v1/owner/gdpr/export
Returns every row in the database that names the given tg_user_id.
Suitable to forward to the requester to satisfy the data-subject access
right.
curl -H "X-Telegram-Init-Data: $INIT_DATA" \
-H "X-TOTP-Code: $CODE" \
-H "Content-Type: application/json" \
-d '{"tg_user_id": 123456789}' \
${PUBLIC_BASE_URL}/v1/owner/gdpr/export
Response is a JSON document with one block per table:
{
"tg_user_id": 123456789,
"generated_at": "2026-05-18T12:00:00+00:00",
"total_rows": 42,
"blocks": [
{"table": "clients", "rows": [{...}]},
{"table": "tracked_users", "rows": [{...}]},
{"table": "subscriptions", "rows": [{...}]},
{"table": "billing_invoices", "rows": [{...}]},
{"table": "status_events", "rows": [{...}, ...]}
]
}
Bytes columns (session blobs, chain hashes) are reduced to
<N bytes> placeholders — they don't carry meaningful data to a human
reader.
POST /v1/owner/gdpr/erase
Deletes the two FK-CASCADE roots — clients and tracked_users — and
inserts a permanent protected_users(reason=gdpr_opt_out) marker so
future tracking attempts on the same tg_user_id refuse.
Always run with dry_run=true first:
# 1. Dry run
curl -H ... -d '{"tg_user_id": 123, "dry_run": true}' \
${PUBLIC_BASE_URL}/v1/owner/gdpr/erase
# Returns row counts without changing anything.
# 2. Confirm
curl -H ... -d '{"tg_user_id": 123, "dry_run": false}' \
${PUBLIC_BASE_URL}/v1/owner/gdpr/erase
Refusals you'll see:
| Refusal | Reason |
|---|---|
tg_user_id is the platform owner | You're trying to erase yourself. |
tg_user_id is a platform admin | One of your own admins. |
tg_user_id is one of our userbots | Operator-controlled account. |
tg_user_id is in protected_users.system | Hard system protection. |
What about audit_log?
The audit log is intentionally not touched by erasure. Its hash-chain
is the platform's tamper-evidence record and is designed to be
append-only. Entries that named the subject's tg_user_id remain for
compliance traceability; references through audit_actors go through
integer IDs, so deleting the user's clients row removes the
identifying linkage anyway.
If a regulator demands literal removal from the audit log too, the operator runs a separate SQL redaction pass — keep that as a manual step, and re-compute the chain hashes downstream of the redacted row.
Audit trail
Both endpoints write an audit_log entry at severity high:
gdpr.export_requested— names the operator and the subject'stg_user_id, plustotal_rowsexported.gdpr.opt_out_requested— names the operator and the subject, plusdry_runflag, per-table delete counts, and whether theprotected_usersmarker was newly inserted.
Filter from the audit endpoint:
GET /v1/owner/audit?event_name_prefix=gdpr.
Future work
A handful of items from the Completion plan expand this:
- An owner-panel UI for the two endpoints (no need to
curl). - An automated retention purger that deletes records past their tier's configured retention window — the same erasure code path, run by a worker on a schedule instead of by an operator one-off.
- A redaction tool for the audit log when literal-immutability conflicts with the regulatory ask.