# Privacy: access, erasure, consent and bots

Tools for GDPR-style requests and consent, and for keeping bots out of the visitor list.

## Right of access: export

Everything Snakkbar stores about one visitor, as JSON (**VisitorExport**): the visitor row
(profile, attributes, live state, device, location), page views, conversations with every message,
their translations and attachments (with signed links valid for 10 minutes), and callback requests
made in those conversations, and the visitor's feedback (thumbs on answers, conversation ratings).
Internal tokens and assistant bookkeeping are left out, and so are internal notes between staff
(`kind: "note"`) with files attached to them, and the staff-only `rating` transcript lines.

| Who | Request |
| --- | --- |
| Admin UI | Visitor detail → *Personvern* → **Eksporter data**, or the visitor panel of a conversation |
| Admin operator (API) | `GET /operator/visitors/:id/export` · SDK `operatorClient.visitors.export(id)` |
| Your backend | `GET /server/visitors/:externalId/export` (secret API key; identified visitors) |

## Right to erasure

**Slett alle data om denne besøkende** (admin, with a typed confirmation) or the API deletes, in one
transaction:

- the visitor: profile, attributes, live state (current page, device, location), page views;
- their conversations with all messages, translations, read markers and participants;
- their images, also from storage (unsent uploads included);
- callback requests made in those conversations;
- knowledge suggestions drawn from those conversations that were not approved (approved ones are
  team knowledge and stay, without the link to the conversation);
- outbox events and webhook deliveries that mention the visitor or their conversations, and
  rate-limit counters keyed by the visitor.

Then the assistant runtime (eve) is asked to clear its copy of each conversation (best effort; an
expired session follows eve's own retention), staff views refresh, and a **`visitor.deleted`**
event goes out (`{ visitorId, externalId, by }`), so [webhooks](webhooks.md) can propagate the
erasure to your CRM. This cannot be undone.

| Who | Request |
| --- | --- |
| Admin UI | Visitor detail → *Personvern*, or a conversation's visitor panel |
| Admin operator (API) | `DELETE /operator/visitors/:id` → `204` (`404` if unknown) · SDK `operatorClient.visitors.remove(id)` |
| Your backend | `DELETE /server/visitors/:externalId` → `204`, also when unknown or already erased |

Erasing does not touch the browser: an anonymous visitor's stored token simply stops working, and
the SDK creates a new visitor next time. Data also leaves Snakkbar by itself after the tenant's
retention period (Settings → *Slett samtaler etter*).

## Consent

Tenant setting **Vent på samtykke før sporing** (`TenantSettings.consent`, `"none"` by default,
or `"required"`; also in `PublicConfig.consent`). What it means, precisely:

| | Consent not given (pending or denied) | Granted |
| --- | --- | --- |
| Live tracking (heartbeats, page views, cursor while watched) | **Off.** Nothing is sent | On (if live visitors is on) |
| Creating a visitor | **Not** for tracking or for `setUser` | As needed |
| Opening the chat (a user action) | Creates a visitor **for the chat**; the conversation works fully. Pages are still not tracked | Same |
| `setUser` / `data-user-*` | Held in the page; sent only once the visitor opens the chat (or consent is granted) | Sent |
| Storage on the device | No visitor token until the visitor opens the chat; then the chat's own keys (visitor token, open conversation). A choice, once made, is remembered | Same |

With `"none"`, tracking runs by default, but an explicit **denial always wins**: a site that reports
`denied` is never tracked, whatever the tenant setting.

**Withdrawing** (`consent(false)`) stops tracking at once, closes the chat, and forgets the
anonymous visitor on this device (`snakkbar:<tenant>:visitor` and the open conversation). The
conversations stay on the server until retention or erasure. Granting again later tracks as a new
visitor. For identified visitors (token from your backend) the token is yours and is kept; tracking
stops.

The choice is remembered per tenant in `localStorage` (`snakkbar:<tenant>:consent` =
`granted`/`denied`). Consent is enforced in the browser (the SDK and widget); the server cannot
know a visitor's consent.

### Telling Snakkbar

| Where | How |
| --- | --- |
| Script tag | `data-consent="granted"`, `"denied"` or `"pending"` (wait for a call, even when the tenant does not require consent). `granted`/`denied` are remembered like a call |
| Global API | `Snakkbar.consent(true \| false)` (queued until the widget has mounted) |
| `mountWidget` | `mountWidget({ …, consent: "granted" \| "denied" \| "pending" })`, later `controller.consent(true \| false)` |
| SDK | `createVisitorClient({ …, consent })`, `client.setConsent(granted)`, `client.getConsent()` → `"granted" \| "denied" \| "pending"`. `startTracking()` waits while consent is needed and starts by itself on `setConsent(true)` |

A `pending` from the script tag or option never overrides a choice the visitor made earlier.

### Wiring a consent manager

Most consent managers (CMPs) call you back when the visitor decides and again when they change
their mind. Map the category that covers analytics/statistics to Snakkbar:

```html
<script src="https://<host>/widget.js" data-tenant="your-tenant" data-consent="pending" defer></script>
<script>
  // Generic: call this from your CMP's "consent given/changed" callback.
  function snakkbarConsent(granted) {
    if (window.Snakkbar && window.Snakkbar.consent) window.Snakkbar.consent(granted);
    else {
      // The widget has not loaded yet: it reads the attribute when it starts.
      var tag = document.querySelector("script[data-tenant][src*='widget']");
      if (tag) tag.dataset.consent = granted ? "granted" : "denied";
    }
  }

  // Cookiebot
  window.addEventListener("CookiebotOnConsentReady", function () {
    snakkbarConsent(Boolean(window.Cookiebot && Cookiebot.consent.statistics));
  });

  // Google Consent Mode (gtag) users can call snakkbarConsent from the same place they call
  // gtag("consent", "update", { analytics_storage: "granted" | "denied" }).
</script>
```

Headless with the SDK:

```ts
const client = createVisitorClient({ host, tenantId, consent: cmp.hasConsent("statistics") ? "granted" : "pending" });
const stop = client.startTracking();
cmp.onChange((categories) => client.setConsent(categories.includes("statistics")));
```

## Bot protection

**Obvious bots never become visitors.** In the browser, the SDK does not start live tracking (and so
creates no visitor) in automated browsers (`navigator.webdriver`) and for crawler/HTTP-library user
agents (Googlebot, Bingbot, GPTBot, HeadlessChrome, curl, python-requests, link previewers, …).
On the server, `POST /visitors` refuses the same user agents with `403 forbidden`. The check only
looks for clear signals, so unknown browsers and apps pass. The per-IP limit on creating visitors
stays (120 per 10 minutes). The list is `packages/sdk/src/bots.ts` (`isBotUserAgent`,
`isAutomatedBrowser`, exported from the SDK).

Note for test automation: headless Playwright/Puppeteer counts as a bot. Set a normal user agent
and hide `navigator.webdriver` (see `packages/widget/dev/visual.mjs`) to test tracking.

**Cloudflare Turnstile (optional).** Set both environment variables on the deployment:

| Variable | |
| --- | --- |
| `TURNSTILE_SITE_KEY` | Public site key; sent to browsers in `PublicConfig.turnstileSiteKey` |
| `TURNSTILE_SECRET_KEY` | Secret key; the server verifies tokens with Cloudflare |

With both set, `POST /visitors` requires a token (`turnstileToken` in the body): `403
challenge_required` without one, `403 challenge_failed` for a rejected one. The SDK handles it: it
loads `https://challenges.cloudflare.com/turnstile/v0/api.js` only then, solves the challenge
invisibly (`appearance: "interaction-only"`: a small box appears at the bottom left only if
Cloudflare needs a click), and sends the token. Creating a visitor happens once per device, so
returning visitors are not challenged again. If Cloudflare cannot be reached, visitors are let
through (logged) rather than locked out. Sites with a Content Security Policy must allow
`https://challenges.cloudflare.com` in `script-src` and `frame-src`. Use Cloudflare's widget mode
*Managed* or *Invisible*. Clients without a browser (the SDK in Node) cannot solve the challenge;
use identified visitors (`POST /server/visitor-tokens`) there, which never need it.
