Helios
Security

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:

RefusalReason
tg_user_id is the platform ownerYou're trying to erase yourself.
tg_user_id is a platform adminOne of your own admins.
tg_user_id is one of our userbotsOperator-controlled account.
tg_user_id is in protected_users.systemHard 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's tg_user_id, plus total_rows exported.
  • gdpr.opt_out_requested — names the operator and the subject, plus dry_run flag, per-table delete counts, and whether the protected_users marker 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.

On this page