What personal data of an end customer (a website visitor or a contact of a tenant) this product stores, where, why, how long, and what the data-subject tools do with it. Tenants can use it for their record of processing activities; the platform owner for theirs (as processor). Staff (operator) accounts are listed at the end for completeness — they are not covered by data-subject requests.
Engineering documentation, not legal advice. Retention defaults are product defaults; each tenant decides its own periods (Settings → Privacy & data → Retention) within the platform maximums.
How a person is linked
| Link | Meaning |
|---|---|
| contact id | CRM profile, one per email address per website (contacts). |
| visitor id | One browser session (visitors), or the stand-in “visitor” of an email address / messaging-channel user. A visitor has at most one conversation. |
| conversation id | Everything said with that visitor (conversations → messages). |
| email address | Contacts, verified/unverified visitor email, email-channel identities, campaign recipients, status-page subscribers, CSV import logs. |
| IP address | visitors.geo.ip (unless “Do not store visitor IP”), webhook payload copies, server request logs. |
| free text | Message text, notes, email bodies, AI answers / translations / summaries, rating comments, chatbot answers. |
Who counts as the person (identity matching). A request is about a contact and/or an email address. Matched
automatically: sessions whose email was verified (identity signature) and the email channel’s identity for
the address when the address is proven there — at least one inbound mail from it was authenticated by the
mailbox provider (its own top-most Authentication-Results: DMARC pass or aligned DKIM / SPF pass; IMAP only, with
the authserv-id configured in the email settings) or answered an operator’s reply (our Message-ID or signed reply
token). A forged From: alone proves nothing. Sessions where someone only typed the address (and unproven
email threads) are listed separately as “unverified” and only included when the admin ticks the box. Erasure always removes the address itself from those sessions (“detach”),
even when their content is left alone.
Data categories
Erasure modes: Delete removes the row (or the file); Anonymise keeps the row for the tenant’s records and
reports but removes identifiers; Detach (unverified sessions not included) strips only the subject’s
address / matching name and phone. “Scrub” = the subject’s known email, phone, name and IP are replaced by
[erased] in free text (website-wide for email and phone).
| # | Category (registry key) | Where it lives | Linked by | Purpose | Retention after this work | Erasure: Delete / Anonymise |
|---|---|---|---|---|---|---|
| 1 | Contact profile (profile) |
contacts (email, name, phone, company, avatar, segments, custom data, operator notes, unsubscribe flag + date, source), privacy_legal_holds |
contact id, email | CRM, campaigns | Kept until deleted or erased | Deleted in both modes (the profile is the identity) |
| 2 | Sessions, devices, locations, page history (sessions) |
visitors (email, verified flag, nickname, phone, avatar, custom data, segments, device + user agent, geo incl. IP/city/coordinates, current page), page_views (url, title, referrer), privacy_consents (policy version + time) |
visitor id | Chat, routing, analytics, identity | Visitors who never chatted: 395 days after last seen · page views: 395 days · consents: with the visitor | Delete: rows removed (cascade). Anonymise: email/phone/avatar/custom data/segments removed, name → pseudonym, IP + region/city/coordinates removed (country kept), user agent / screen / timezone removed, URL query strings removed, referrer → origin, page titles removed, browser session token invalidated |
| 3 | Conversations (conversations) |
conversations (status, subject, last-message preview, rating + comment, tags, assignment), platform_locked_conversations |
conversation id | Inbox | Default never; per tenant: delete or anonymise N days after resolved / after last activity | Delete: removed. Anonymise: preview, subject and rating comment scrubbed (or [redacted]) |
| 4 | Messages (messages) |
messages (text, notes, files, chatbot pickers, timeline events — e.g. “email set to …”), message_revisions (edit history) |
conversation id | The conversation itself | With the conversation | Delete: removed incl. edit history. Anonymise: scrubbed; optional “redact message text” replaces text/notes and rating comments with [redacted]; edit history removed. Elsewhere in the website the subject’s email / phone are scrubbed from any message |
| 5 | Uploaded files (files) |
uploads rows + objects on disk (DATA_DIR/uploads/<website>/<key>) |
via file messages of the conversation | Attachments | Default never; per tenant N days (message keeps a “file no longer available” placeholder) | Removed from disk in both modes, quota freed; anonymise leaves the placeholder |
| 6 | Emails (emails) |
email_messages (from/to/cc, subject, full original text and quoted part, Message-ID), email_identities (address → visitor) |
conversation id, email | Email channel threading, replies | Originals: 180 days (headers + thread message stay) | Delete: removed. Anonymise: addresses and originals removed, subject scrubbed; identities removed. Other conversations: the address is scrubbed from headers and originals |
| 7 | AI data (ai) |
ai_answers (question, answer), ai_translations (cache), ai_conversation_state.summary (cache), ai_bot_sessions |
conversation id | Operator assist, answer bot | Caches: 90 days (re-created on demand); answers with the conversation | Caches purged in both modes (and website-wide where they quote the address/phone); answers deleted, or scrubbed / redacted |
| 7a | Live translate originals (ai.live_translate) |
ai_live_outgoing (an agent’s own wording of a reply that was sent translated, its language and the language sent — the message row holds what the visitor received; operators only). ai_conversation_state also keeps the customer’s language and where it came from |
conversation id, message id | Show agents what they wrote; audit of what was sent | With the conversation (removed with the message, and when the reply is edited or deleted) | Delete: removed. Anonymise: the subject’s identifiers scrubbed from the original (or [redacted]); website-wide the address / phone is scrubbed. Exported as data/live-translate.json (handler in modules/ai/privacy.ts) |
| 7b | Diagnostics runs (diagnostics) |
diagnostics_runs (the domain an agent checked for the customer, every check’s plain-sentence finding and evidence — DNS answers, HTTP status / redirect chain, certificate dates, a page title at most, never page bodies or cookies — the verdict, who ran it; for a verified WHMCS client also the matching service / invoice / domain status. Operators only; each run also leaves one private note, which is a message (row 4)). diagnostics_settings is tenant configuration (exempt) |
conversation id | Agent-facing troubleshooting of the customer’s website / email | With the conversation (FK cascade + conversation retention); at most 20 runs per conversation | Delete and Anonymise: removed. Exported as data/diagnostics.json (handler in modules/diagnostics/privacy.ts) |
| 8 | Webhook delivery logs (webhooks) |
webhook_deliveries.payload — the exact JSON sent (contains message text, visitor email, IP, geo), response bodies |
ids + identifiers inside the payload | Retries, debugging | 30 days (pending retries are never cut short) | Every stored delivery that mentions the person (any id or identifier) is removed in both modes |
| 9 | Campaign emails (campaigns) |
campaign_recipients (email, name, company, status, sent at, mailbox provider of the address, whether the email was tracked, bounce kind + time) |
contact id, email | Campaign sending + stats | With the campaign | Removed in both modes (aggregate campaign stats lose the row) |
| 9a | Campaign opens, clicks and tracking preferences (campaign-tracking) — only when the website turned on open/click tracking |
campaign_events (per open / click: time, kind, classification — person / Apple privacy proxy / Gmail or Yahoo proxy / security scanner — and a reason code, the clicked link, and only for a person: ISO country, city when the website keeps city-level location, device class, mail app / browser family). No IP address and no user-agent string is stored: both are used in memory to derive the above and discarded. campaign_contact_flags (“do not track” set by the team or by the person through the link in the email, the address that bounced hard, the bounce reason + time) |
recipient → contact id, email | See where / how campaign emails are read; leave out addresses that do not exist | Events: the website’s page-view retention (default 395 days), swept every few hours; legal-hold contacts kept. Flags: with the contact | Removed in both modes (handler in modules/email/tracking/privacy.ts); exported as data/campaign-email-activity.json |
| 10 | Email sequences (sequences) |
crm_sequence_runs, crm_sequence_events, crm_sequence_seen |
contact id | Drip emails | With the contact | Removed in both modes; active runs are cancelled (removed) so no further step is sent |
| 10a | Sequence confirmation requests (sequence_confirmations) |
crm_email_confirmations (when a double opt-in email was sent / confirmed; the address only as a keyed hash, the link token hashed, the visitor session id) |
contact id | Double opt-in for addresses only an unverified visitor typed (capped per address / website / visitor) | With the contact | Removed in both modes (handler in modules/crm/privacy.ts) |
| 11 | Contact activity (crm_activity) |
crm_activity (timeline incl. subscription / segment changes) |
contact id | CRM timeline, consent history | With the contact | Removed in both modes |
| 12 | Automations (automations) |
automation_trigger_fires (which proactive message was shown when), automation_runs (chatbot runs). Answers collected by chatbots are stored in the session’s custom data (row 2) |
visitor id, conversation id | Frequency capping, chatbot | With the visitor / conversation | Delete: removed. Anonymise: kept (no identifiers) |
| 13 | Screen-sharing control audit (cobrowse) |
cobrowse_control_sessions (operator name, times, action counts — typed text is never stored) |
conversation id | Security audit of remote control | With the conversation | Delete: removed. Anonymise: kept |
| 14 | Analytics facts (analytics) |
analytics_conversation_facts (first page origin + path, response times, stars), analytics_visitor_activity (visitor id per hour) |
conversation / visitor id | Reports | With the conversation / visitor | Delete: removed. Anonymise: kept (no identifiers) |
| 15 | Push receipts (push_receipts) |
push_receipts (operator ↔ conversation, time) |
conversation id | Avoid duplicate unread emails | 7 days | Delete: removed |
| 16 | Status-page subscriptions (status_subscriptions) |
status_subscribers (email), status_notifications (outbox) |
Incident mail | Until unsubscribed / erased | Removed in both modes | |
| 17 | Help-article votes (helpdesk_feedback) |
helpdesk_feedback — widget votes as v:<visitor id>; public-page votes as a SHA-256 of IP + user agent |
visitor id (widget votes only) | One vote per person | With the article | Widget votes removed in both modes. Public-page votes cannot be attributed to a person (hash) and are not erased by a request; article counters are anonymous aggregates |
| 18 | Imports (imports) |
import_refs (source system ids ↔ local ids), import_errors (messages may contain an address), import_runs.sample, crm_imports (problem rows with addresses, preview rows, stored CSV file on disk) |
local ids, email | Re-runs without duplicates, checking an import | Import logs and stored CSV files: 30 days after a finished import | Refs removed; error rows mentioning the address removed; sample / preview / problem rows filtered; the stored CSV of a finished import that contains the address is deleted |
| 19 | Earlier data exports (privacy_requests) |
Export ZIPs on disk (DATA_DIR/privacy-exports) + request rows (masked label, keyed subject reference; the resolved scope is sealed while a job runs) |
subject reference | Answering access requests | ZIPs: 7 days | An erasure deletes earlier export files about the same person and relabels their rows |
| 20 | Messaging channels (registered by the channels feature) | channel_identities (Telegram id / phone / Messenger PSID / Instagram IGSID, profile name, username, profile: stored avatar copy + profile link), channel_inbound_events.payload, channel_messages, channel_consents, channel_audit |
visitor id, contact id | Telegram / SMS / WhatsApp / Messenger / Instagram | See the channels feature | Handlers in modules/channels/privacy.ts (erasure also deletes the stored avatar upload). Exempt: channel_policies (destination countries + daily cap for operator-started SMS / WhatsApp), channel_meta_apps (app credentials), channel_meta_oauth_flows (admins’ short-lived logins), channel_meta_deletions (hashes + counts of Meta data-deletion / deauthorize callbacks), channel_meta_signed_requests (hashes of accepted signed_requests, ~1 h replay guard). Inbound queue rows are deleted after 30 days (processed) / 90 days (never processed). Meta data-deletion requests run eraseScope for matching identities |
| 21 | Consent to the chat notice | privacy_consents |
visitor id | Proof of consent | With the visitor | Part of row 2 (exported; removed with the visitor; kept on anonymise — it holds no identifier) |
Kept on purpose after an erasure
| What | Where | Why |
|---|---|---|
| Suppression record | privacy_suppressions: HMAC-SHA256 of the lower-cased address (key: APP_SECRET + website), “erased”, date, optional “keep blocking” |
So a later CSV import, sync or campaign does not silently re-create and re-email the person. Imports skip the address (“Erased on request (do not contact)”), sequences never enrol it, a contact re-created by an operator or the person stays unsubscribed. Not reversible without the server secret. |
| Audit entries | privacy_audit |
Who requested / completed the export or erasure, when, mode, counts per category, a keyed subject reference — never the address. Subject to the audit-log retention (default 1 year). |
| Completion certificate | privacy_requests.certificate |
Signed JSON (HMAC-SHA256 over the canonical body): counts per category, options, backup statement, limits. No personal data. |
contact.erased webhook |
tenant’s endpoints | Opaque ids of what was removed (contact / visitor / conversation ids), mode and time — so the tenant’s own systems can follow suit. No personal data. |
Not reached by an erasure
| What | Where | Behaviour |
|---|---|---|
| Backups | deploy/backup.sh: database dump + uploads. Off-site mode (encrypted with age, docs/operations.md): 14 daily + 8 weekly + 12 monthly sets (BACKUP_KEEP_*, up to about 13 months), 3 days of encrypted local copies. Local-only mode: nightly plain files, 14 days (BACKUP_LOCAL_KEEP_DAYS) |
Not rewritten. Erased data can stay in backups until they age out; it is never restored into the product automatically. Restoring a backup re-introduces erased people — re-run the erasures listed in the audit log after a restore. |
| Server request logs | stdout → journald / container logs | Contain request IPs and URLs (visitor tokens and invite/login tokens are redacted). Retention is the host’s log configuration. |
| Reverse proxy / CDN logs | Caddy / nginx / Cloudflare | Outside the product; configure their retention. |
| In-memory caches | WHMCS summary (≤ 60 s), presence, website settings (≤ 30 s) | Expire on their own. |
| Copies outside the product | Webhook receivers, the tenant’s email inbox (transcripts, notifications), WHMCS, push notifications already delivered to operator devices | The tenant’s own systems. |
| Free text | Messages, notes, emails | Delete removes them. Anonymise keeps them unless “redact message text” is ticked: only known identifiers are replaced; other personal data written in free text stays. |
Retention defaults and why
| Policy | Default | Why |
|---|---|---|
| Conversations | Never | Nothing changes on upgrade; the tenant decides (delete or anonymise, after resolved or last activity). |
| Visitors who never chatted | 395 days | Device / location / page data of people who never chatted; 13 months is the common limit for analytics data. |
| Page-view history | 395 days | Same reasoning. |
| Uploaded files | Never | Files are part of conversations; removing them silently would change records. |
| Webhook delivery logs | 30 days | Retries finish within ~24 h; the rest is only for debugging, and the bodies contain message text. |
| Email originals | 180 days | The thread keeps the visible message; originals are for debugging and “show quoted text”. |
| Import logs + stored CSV files | 30 days | Only needed while checking a finished import. |
| AI caches | 90 days | Re-generated on demand. |
| Push receipts | 7 days | Only used for minutes (unread-email coordination). |
| Audit log | 365 days | Long enough for security reviews and to show requests were handled. |
| Export downloads | 7 days (fixed) | Long enough to hand over; short enough not to become a second copy. |
Platform admins can set maximums per policy (/platform/privacy). A tenant cannot save a longer period or
“never” when a maximum exists; with a maximum on conversations, “never” becomes “anonymise after the maximum”.
The nightly job (01:00–06:00 UTC) applies the policies in bounded batches (500 rows per policy per run; the rest
continues the next night), never touches contacts on legal hold, and writes the retention log. The dry run counts
exactly what a run would remove.
Staff (operator) data — not covered by data-subject requests
operators, sessions, account_tokens, authx_*, memberships, invites, push_subscriptions,
push_preferences, inboxorg_preferences, inboxorg_views, inboxorg_mentions, platform_admins,
platform_audit, privacy_audit (actor name + IP). These are the platform’s / tenant’s own staff records,
handled by account deletion. Exports show staff by name only.
Onboarding (exempt, no end-customer data): onboarding_states holds the tenant’s setup progress — timestamps, the
first page URL of the tenant’s own site where the widget was seen, the last “Check my site” result and the ids of
the owner’s own test visitors from the try-it page; onboarding_operators holds staff preferences (orientation
seen, setup emails on/off). Both cascade with their website / operator.
Website icons (exempt, no end-customer data): website_icons + websites.icon_url hold the tenant’s own icon — its
public favicon or an admin’s upload (file under the website’s storage folder), where it was downloaded from and the
discovery schedule. Deleted with the website (cascade + storage folder).
Campaign builder (exempt, no end-customer data): campaign_brand_kits holds the sender’s own brand kit (logo, colour,
sender / company name, the business’s postal address, footer text and links); campaign_templates holds email
designs operators saved as templates. Campaign pictures are operator uploads in uploads (like help-center images).
A “specific people” campaign audience (campaigns.audience, kind contacts) lists contact ids only; an erased
contact’s id then matches nothing (the contact row is gone) and is never mailed. All cascade with their website.
Campaign tracking (exempt, no end-customer data): campaign_links holds the destinations of the rewritten links of a
measured campaign — written by operators; links with personalisation (merge tags) are never rewritten, so a
contact’s details never end up there. campaign_tracking_settings holds the website’s opt-in, the footer-notice
switch and DKIM selector names learned from our own returned mail. Both cascade with their campaign / website.
Sending (exempt, no end-customer data): sending_domains holds the website’s own sending domain, its ownership token,
the provider’s DKIM / return-path records and their DNS check sentences; sending_pauses whether the reputation guard
or a platform admin paused bulk sending (a rate sentence or the admin’s note); sending_daily per-day counts (sent,
bounces, complaints — numbers only); sending_events the ids and kinds of provider notifications already processed
(kept 14 days, never addresses); sending_platform the platform’s notification set-up. Bounces and complaints that the
provider reports act on existing data only — the campaign recipient’s bounce fields and campaign_contact_flags
(row of campaign tracking) and the contact’s unsubscribe flag — so their handlers cover them. Mail sent through the
service passes through the provider (Amazon SES), which keeps its own sending logs and suppression list under the
platform operator’s account. Tenant tables cascade with their website.
Trust & safety (exempt, no end-customer data): trust_settings holds the platform’s knobs (blocked / allowed sign-up
domains, caps) and “admins were emailed” markers; trust_tenants a tier a platform admin pinned on a website (with the
admin’s note); trust_mail_daily per website and day how many shared-mailbox emails were sent / refused (numbers);
trust_recipient_daily per day a count of unauthenticated-trigger emails (status-page confirmations, sign-up
verification, password reset) keyed by a salted HMAC of the recipient address — never the address — kept two days;
trust_signup_blocks refused sign-ups per day and reason (numbers). trust_abuse_reports holds public abuse reports
about tenants’ pages: the reported URL, category, the reporter’s own description and optional email for follow-up —
platform records written by a third party, not a tenant’s visitors or contacts, never the reporter’s IP (the per-IP
rate limit uses a hash). Reports keep a tenant_id without a foreign key so they outlive a termination; resolved
reports are deleted after a year. Tenant tables cascade with their website.