API Docs

GDPR + data subject requests

AdminUpdated Sep 19, 2026

GDPR + data subject requests

How Chatly handles right-to-access, right-to-rectification, right-to-erasure, and right-to-portability — plus the operator runbook for executing them.

GDPR — and its siblings CCPA, LGPD, PIPEDA, the UK GDPR — gives end- users four core rights over their personal data:

Data subject rights

Name

Type

Description

Right to access

Article 15

Get a copy of all personal data the controller has stored.

Right to rectification

Article 16

Correct inaccurate data.

Right to erasure

Article 17

Have personal data deleted ("right to be forgotten").

Right to portability

Article 20

Receive personal data in a machine-readable format and have it transmitted to another controller.

Each Chatly customer (the workspace owner) is the data controller; Chatly is the data processor. The DPA at /legal/dpa spells out the contractual posture; this page covers the operator runbook, and it documents only what the API actually implements today.

Info — You execute the request; we provide the tools

GDPR puts the duty on the controller (you). Chatly gives you the endpoints + audit log to do it. The decision about whether a request is valid, urgent, or scope-restricted is yours — we don't make controller-level judgment calls.

Right to access + portability

One endpoint, per contact, synchronous:

GET /v1/gdpr/contacts/{id}/export — Bearer token

GET /v1/gdpr/contacts/contact_01H7.../export
Authorization: Bearer <session token>

It requires the contacts:export permission, which resolves to owner and admin only — an agent gets a 403. The response body has exactly four keys:

{
  "contact":       { /* the contact row */ },
  "conversations": [ /* every conversation whose contact_id is this contact */ ],
  "messages":      [ /* every message on those conversations */ ],
  "cdpEvents":     [ /* CDP events for this contact */ ]
}

Info — What this endpoint is not

There is no ?format= parameter, no async job for large exports, and no signed-URL bundle. The response is one JSON document, built in the request. Message rows carry their attachments column as stored — Chatly does not mint download URLs for attachment objects as part of the export, so if the data subject needs the files themselves you fetch them from your object storage.

Contact-initiated requests

An end-user with a portal session (magic link) can raise their own request from Preferences in the contact portal:

POST /v1/portal/me/export — Bearer token

POST /v1/portal/me/delete-request — Bearer token

Both create a row in the review queue rather than acting immediately. Requests are idempotent per (contact, kind) while one is still open, and each carries a 30-day expiresAt — the statutory response window, which is also what the queue sorts against.

GET /v1/gdpr/requests — Bearer token

POST /v1/gdpr/requests/{id}/approve — Bearer token

POST /v1/gdpr/requests/{id}/reject — Bearer token

All three require contacts:delete (owner + admin). The split is deliberate:

  • Export requests are enqueued for the worker as soon as the contact raises them — the portal session already proves identity, so there is nothing for a human to decide. approve refuses anything that is not a delete.

  • Delete requests sit in verified until an operator approves them. Approval stamps the actor, appends an audit step, and enqueues the erasure pipeline. Rejection stamps rejected with an optional reason and enqueues nothing; the contact is not notified automatically.

Warning — Fulfil access requests with the operator endpoint

The worker builds the export bundle and stores it, then emails the contact a link to a self-service download page. That page's API route is not implemented — the link does not resolve. Until it is, treat a contact-initiated export as a notification that a request exists and fulfil it with GET /v1/gdpr/contacts/{id}/export above.

Right to rectification

The contact can correct their own record from the portal:

PATCH /v1/portal/me — Bearer token

{ "name": "Corrected Name", "locale": "en-GB", "timezone": "Europe/London" }

That covers name, locale, timezone and email subscription preferences — and nothing else.

Operator-side, the correcting call is the contact upsert:

POST /v1/contacts — Bearer token

{ "externalId": "crm-4417", "email": "[email protected]", "name": "Corrected Name" }

When externalId matches an existing contact, the upsert overwrites email, phone, name, avatarUrl, locale, timezone and merges attributes. It requires contacts:write and accepts a workspace API key as well as a session.

Warning — Two limits worth knowing before you promise a correction

There is no PATCH /v1/contacts/{id}. If the contact has no externalId, the upsert has nothing to match on and creates a second contact rather than correcting the first — so rectification by API only works for contacts that arrived with an external id.

A profile correction writes no audit_log row. The audit vocabulary covers contact.blocked and contact.unblocked and no other contact mutation. What a correction does emit is a contact.updated webhook event naming the changed fields — subscribe to it if you need a paper trail.

Right to erasure

DELETE /v1/gdpr/contacts/{id} — Bearer token

Requires contacts:delete. In production the request must carry an Idempotency-Key header or it is rejected with 428. It works on a suspended workspace by design: a DSAR clock does not stop because a workspace stopped paying.

The same erasure set runs whether an operator calls this endpoint or the worker processes an approved contact request — the definition lives in one file and both paths use it:

  1. Contact row anonymised — email, phone, avatar_url, external_id set to NULL; name set to Deleted contact; attributes replaced with { gdpr_deleted: true, deleted_at: … }.

  2. author_id scrubbed from every message the contact authored, and each is stamped metadata.gdpr_anonymized = true. Bodies are kept.

  3. CDP events for the contact deleted.

  4. Push tokens deleted — they are live credentials, not just PII.

  5. Custom-object instances deleted (operator-entered fields hanging off the contact).

  6. AI memories deleted — inferences about a person who asked to be forgotten have no retention argument.

  7. An audit_log row is written: gdpr.contact_forgotten when an operator ran it, gdpr.contact_deleted from the contact pipeline.

Info — Why we keep message bodies, not delete them

Three reasons:

  1. Agent work history — the support agent's contribution shouldn't be erased.

  2. Aggregate metrics (CSAT, response time, SLA reports) shouldn't break retroactively.

  3. Legal hold — if the conversation is evidence in an ongoing dispute, deleting it is the wrong move.

The identifier tying the transcript back to the human is removed; the transcript stays.

Warning — What erasure does not reach

Say this out loud in your DSAR response rather than discovering it later:

Voice. voice_calls and voice_voicemails carry a contact_id, a recording_url and a transcript, and the erasure set does not touch them. If the contact has call history, delete it yourself.

Attachment objects. Files in object storage are not deleted; only the database rows change.

Agent-written notes. contact_notes — free text an agent wrote about the person — is not in the erasure set, and neither are contact scores or survey responses.

The audit trail. audit_log rows are never erased — that is the proof the request was honoured.

Backups. Nightly dumps age out on their own rotation (RETENTION_DAYS, default 30, in infra/scripts/backup-postgres.sh). Nothing reaches into a snapshot to remove one contact, and nothing flags a snapshot for early purge.

Copies made outside migrations. A table an operator duplicated by hand during an incident is invisible to all of this, and inherits neither RLS nor erasure. Go looking before you certify a deletion.

There is no hard-delete-a-contact endpoint. contacts has no deleted_at column and nothing sweeps anonymised rows away later — the anonymised row is the end state, and it is what makes the conversation history keep its shape.

Retention

Retention is per workspace and off until you configure it. There are no defaults: an unconfigured workspace keeps everything forever.

GET /v1/retention/scopes — Bearer token

GET /v1/retention/policies — Bearer token

PUT /v1/retention/policies/{scope} — Bearer token

DELETE /v1/retention/policies/{scope} — Bearer token

Reads need workspace:read; creating or removing a policy needs workspace:write. The UI is Settings → Data retention. Four scopes are purgeable, and they are the only four the sweep understands:

Retention scopes

Name

Type

Description

conversations

delete

Conversation rows past the window.

messages

delete | anonymise

anonymise blanks the body and detaches the author instead of deleting the row, so counts and response-time reporting stay truthful. It is implemented for this scope only.

webhook_deliveries

delete

Delivery logs.

cdp_events

delete

High-volume behavioural events.

{ "retentionDays": 90, "mode": "delete", "enabled": true }

retentionDays accepts 1–3650. The sweep runs every six hours, is bounded to 5,000 rows per policy per run (a large backlog converges over several passes rather than holding locks for one long one), and stamps lastRunAt + lastDeletedCount back onto the policy. Both are shown on the settings page — a retention promise you cannot verify is the same problem in a nicer wrapper.

Warning — Scopes not covered

Voice recordings and audit-log rows have no retention scope. If you need a recording lifecycle, apply it at the object-storage bucket. Do not tell an auditor that Chatly ages out either one for you.

Data residency

Chatly does not implement per-region data residency. /v1/regions configures per-workspace endpoint selection — which API and realtime URL a workspace's visitors connect to — and nothing more. There is no region column on the workspace that routes reads and writes, no regional database router, and no guarantee that storage stays in a given jurisdiction.

If you need EU-only processing, self-host the whole deployment in the EU. That is the honest answer, and it is a real one: everything Chatly needs runs on your own infrastructure.

Sub-processors

We maintain a public list of every third-party processor that processes Personal Data on behalf of customers. See /legal/subprocessors.

Info — BYOK reshapes the sub-processor list

Because Chatly is BYOK at the platform layer, most "sub-processors" in the traditional sense are your sub-processors (your Twilio, your OpenAI, your Postmark) — not ours. We list both kinds for transparency, but contractually we're only the data processor for what flows through our infrastructure.

DPA (Data Processing Agreement)

The DPA at /legal/dpa covers:

  • Standard Contractual Clauses (EU Commission 2021/914) for transfers outside the EEA/UK.

  • Sub-processors: the list is published, and you may subscribe to be notified before a new one is added.

  • Audit rights — once per year, with 30 days' notice, under NDA, at your cost unless we are in material breach.

  • Breach notification without undue delay and in any event within 72 hours of us becoming aware.

The DPA supplements the Terms of Service and applies whenever Chatly processes Personal Data on your behalf; there is no separate signature step. Self-host customers don't need one with us — the data never reaches our infrastructure — but should have one with each of their own BYOK providers.

Operator-side audit

Erasure is audited. Two actions exist, and they are the whole GDPR vocabulary:

GDPR audit actions

Name

Type

Description

gdpr.contact_forgotten

erasure

An operator called DELETE /v1/gdpr/contacts/{id}.

gdpr.contact_deleted

erasure

The worker pipeline erased a contact after an approved request.

Both are recorded with actor_type: 'system' and no actor user id — the row proves the erasure happened, not who clicked. If you need the human, the approving actor is stamped on the gdpr_requests row's audit array (admin.approved / admin.rejected, with actorUserId), which GET /v1/gdpr/requests returns.

Read the trail:

GET /v1/audit — Bearer token

GET /v1/audit?action=gdpr.contact_forgotten,gdpr.contact_deleted&from=2026-01-01T00:00:00Z

Gated on audit:read — owner and admin only. Parameters are action (comma-separated; unknown values are dropped, not rejected), actorUserId, from, to (all timestamps are full ISO-8601 datetimes), cursor and limit. The response is { items, nextCursor, hasMore } — JSON only. There is no CSV export and no regex filter; unknown query parameters are silently ignored, so a mistyped filter returns an unfiltered page rather than an error.

For SOC 2 evidence collection, page it and convert client-side:

curl -fsSL -H "Authorization: Bearer $TOKEN" \
  "https://api.chatly.example/v1/audit?action=gdpr.contact_forgotten,gdpr.contact_deleted" \
  | jq -r '.items[] | [.createdAt, .action, .targetId] | @csv' \
  > gdpr-audit-2026.csv

Self-service for end users

The contact portal's Preferences page gives an authenticated contact three things: email subscription toggles, "email me a copy of my data", and "submit a deletion request". Both requests land in the review queue described above; the deletion one warns the contact that a human will respond within 30 days.

There is no widget-level "Manage my data" link and no workspace toggle that turns the portal's privacy actions on or off — a contact who can sign in to the portal can raise both requests.

Closing a workspace

Workspace deletion is a platform-operator action, not self-service, and the operator surface authenticates with the x-admin-api-key header (or a user id listed in ADMIN_USER_IDS) — not a workspace session.

DELETE /v1/admin/workspaces/{id} — Admin

POST /v1/admin/workspaces/{id}/restore-deletion — Admin

POST /v1/admin/workspaces/{id}/purge — Admin

Deletion is soft: it stamps deleted_at, suspends the workspace, and writes admin.workspace_deleted. Within 30 days it can be restored. After that the workspace can be purged — one row delete that cascades the whole tenant, with the audit row written first so the trail outlives it.

Warning — Purge is manual

Nothing purges a workspace on a timer. The 30-day window is a restore window enforced on the purge call (inside it, purge returns 409 unless force: true) — it is not a countdown that fires by itself. If a customer asked for deletion, someone has to call purge.

Troubleshooting

Info — A contact-initiated export never arrived

Check GET /v1/gdpr/requests for the row and its audit array — it records each step (portal.requested, export.aggregated, export.upload). If export.upload recorded ok: false, fallback: 'redis', object storage is not configured on the worker and the bundle was written to a Redis key instead. Configure S3 and re-request, or fulfil with the operator export endpoint.

Info — Hard-delete request from a workspace that's in litigation

Surface this to legal. Don't just execute. Article 17's exceptions allow retention for legal-claim defense — but you need a documented decision, not a unilateral one by the on-call agent.

Info — A retention policy shows no lastRunAt

The sweep only touches policies with enabled: true, and it skips any scope outside the four listed above. If lastRunAt is still null more than six hours after you saved the policy, the maintenance queue is not running — check the worker, not the policy.

Was this page helpful?
GDPR + data subject requests