# Cookieless on-site surveys

> Ask your visitors a question in a small card on your own site, with no cookie, no device storage and no visitor id. NPS, ratings, choices and free text.

A survey is one question or a few, shown in a small card on your own site
and answered without leaving the page. Create and read them in **Surveys**
in a site's sidebar.

What makes it cookieless:

- The card sets **no cookie** and writes nothing to `localStorage`,
  `sessionStorage`, `IndexedDB` or anywhere else on the device.
- There is **no visitor id**, in the browser or in the stored answer. A
  response holds the page, the device type and the country, and nothing
  else.
- **A survey can never ask for an email address or a name.** There is no
  question type for one. The six answer types are open text, one choice,
  several choices, a rating, an NPS score, and yes or no.
- Anything typed into an open-text box goes through your site's
  [PII scrubber](/docs/privacy/pii-scrubbing) before it is stored, so an
  email address someone types into a comment box is redacted first.
- Targeting is decided on our servers, so your rules are not readable from
  your page source, and the card is only ever sent the survey it should
  show.

## Asked at most once a day, with nothing stored

Each visitor is asked at most once a day, and at most one answer a day is
kept, without any identifier.

When the card is shown, dismissed or answered, we set a marker in our
in-memory store (Redis) under the **daily visit key**,
`HMAC(daily salt, site, IP, User-Agent)`, the same key that
[counts visitors](/docs/metrics/how-visitors-are-counted). The key is
computed while the request is handled and never stored, the salt it is made
from lives only in Redis and rotates every day, and the marker expires at
the end of the UTC day. Nothing links today's marker to yesterday's, and
nothing links either to a person.

A survey that was offered but never shown (the visitor did not scroll far
enough, or left from another page) sets no marker, so they can still be
asked on the next page.

## Create a survey

The empty page offers five templates, each a complete survey you can then
change: **Exit survey**, **NPS**, **Satisfaction (CSAT)**, **Was this page
helpful?** and **Pricing page**.

| Option | What it does |
|---|---|
| **Name** | For your own list, up to 120 characters. Visitors never see it |
| **Status** | **Draft** collects nothing, **Collecting** offers the card, **Paused** keeps the answers and stops offering it. The results page has **Start collecting** and **Pause** buttons too |
| **Questions** | Up to 5, one per screen, each with a prompt of up to 200 characters. Leave a question's prompt empty to drop it |
| **An answer is needed before moving on** | Makes the question required: **Next** and **Send** do nothing until it is answered. The visitor can still close the card |
| **When it appears** | One of the five triggers below |
| **Where it appears** | Path rules, devices and the share of visitors below |
| **Position** | Bottom right, bottom left or bottom center, 24 px from the edge |
| **Thank-you message** | Up to 200 characters, shown after the last answer for 3 seconds, then the card closes itself. The default is "Thank you." |
| **Starts** and **Ends** | Inclusive dates in UTC. Outside them the survey is not offered, even while it is Collecting |

Rewording a question keeps the answers it already has: each question has an
id of its own, so a breakdown is never split by an edit.

Your prompts, choices and thank-you message appear exactly as you wrote
them, so write them in your visitors' language. The card's own words
(**Next**, **Send**, **Yes**, **No** and the default thank-you) are always
English, whatever language you use in the app.

### The six answer types

| Answer | What the visitor sees | What is stored |
|---|---|---|
| **Open text** | A text box | The text, up to 2,000 characters, PII scrubbed |
| **One choice** | One button per choice. Picking one moves on | The choice's label |
| **Several choices** | The same buttons, which toggle, then **Next** | One row per picked choice |
| **Rating** | Buttons from 1 to 3, 5, 7 or 10 (your scale) | The number |
| **NPS (0 to 10)** | Buttons from 0 to 10 | The number |
| **Yes or no** | Two buttons | `yes` or `no` |

A choice question needs **2 to 10 choices**, one per line in the form, each
up to 100 characters. An answer that was not offered, a rating outside the
scale or a question that is not on the survey is dropped on the way in.

### The five triggers

| When it appears | Exactly when |
|---|---|
| **As soon as the page loads** | As soon as our answer arrives |
| **After a few seconds** | 0 to 600 seconds after our answer arrives |
| **After scrolling down** | At 1% to 100% of the scrollable height. The depth is checked once right away, so a page with nothing to scroll counts as 100% |
| **When someone is about to leave** | With a mouse, the pointer leaving through the top of the window. A touch screen gives no such signal, so there it is a scroll heuristic: after reaching at least halfway down, scrolling back up into the top 15% |
| **When you ask for it** | Waits for a `privatus.ask()` call from your own code |

### Where it appears

Path rules decide which pages ask. Each rule is a match type and a pattern:

| Match | What it matches |
|---|---|
| **Path is** | The path exactly, for example `/pricing` |
| **Path starts with** | Every path under it, so `/docs` covers `/docs/install` |
| **Path matches** | A glob, where `*` stands for any part of a path, including slashes: `/blog/*/comments` |

**Leave the list empty to ask on every page.** Rules are an "or": one match
is enough. Patterns are compared without case, against the path after your
[path masks](/docs/tracker/exclusions-and-masking) are applied, and the
page's hostname has to be one of the site's domains, or a subdomain of one.

| Option | What it does |
|---|---|
| **Devices** | Desktop, Phone, Tablet. Leave all unchecked for every device. The device type is read from the User-Agent in memory and then dropped |
| **Share of visitors** | 1 to 100 percent. The share is rolled per request, so 20% is a sample of traffic and not a group of people followed around the site |

Only **one card is ever shown at a time**. When two surveys match the same
page, the oldest one wins.

## Turn on the `ask` module

The card only appears on pages whose tracking code loads the
[`ask` module](/docs/tracker/modules#ask-on-site-surveys):

```html
<script defer src="https://privatusanalytics.com/js/pa.js" data-site="pa_YOURSITEID" data-modules="engage,auto,vitals,clicks,ask"></script>
```

The snippet in **Site settings → Tracking** adds `ask` for you as soon as
the site has a survey that is Collecting, so copy the snippet again after
you start your first one. The module asks us once per page load (not again
on route changes in a single-page app), and a card appears at most once a
day per visitor.

Leaving `ask` out of `data-modules` is how you turn surveys off at the page
level: without it, nothing is loaded and nothing is requested. With it, the
one request per page load is answered from Redis with no database query, it
is a few hundred bytes, and it loads with `defer` like the tracker itself,
so it never holds up your page. See
[what the request costs](/docs/tracker/modules#what-the-request-costs).

## Read the results

Open a survey for its report. The period picker has **7 days**, **28
days**, **90 days** and **All**, and the report opens on the last 28 days
until you pick one. The header counts responses and how many were finished:
someone who answered the first question and then closed the card counts as
a response, not as finished. A card closed with nothing answered stores
nothing at all.

Each question gets a card with its answer type, its number of answers and:

| Answer type | What the report shows |
|---|---|
| **One choice**, **Several choices** | Every offered choice with its count and its share of the answers, largest first |
| **Yes or no** | Yes and no, with counts and shares |
| **Rating** | The average out of the scale, to two decimals, and a bar per value |
| **NPS** | The score, the counts of promoters (9 and 10), passives (7 and 8) and detractors (0 to 6), and a bar for each value from 0 to 10. The score is the share of promoters minus the share of detractors, rounded to a whole number |
| **Open text** | The 100 most recent answers, newest first, each with its date |

Below the questions, **Pages**, **Devices** and **Countries** list the top
10 of each by number of responses.

## Themes with AI (Business)

On the Business plan an open-text question has a **Find themes with AI**
button once it has at least **5** written answers. It groups the answers
into up to 12 themes, each with a short label in the words of the answers,
a count, a sentiment (positive, neutral or negative) and one example answer
copied from the set.

- It reads the **300 most recent** answers to that question, each cut to
  300 characters.
- It uses Claude Haiku 4.5. The answers are given to the model as data to
  group, never as instructions to follow.
- Each generation costs **one AI question** from your workspace's monthly
  AI allowance, the same meter [Ask](/docs/dashboard/ask) uses (100 a month
  on Business). A generation that fails gives the question back.
- The result is **cached against the answers it was made from**, so opening
  the page again, or ten of your team opening it, costs nothing. New
  answers mean the next run is a new summary.

## Plans and limits

| | Free | Pro | Business |
|---|---|---|---|
| Surveys in the workspace | 1 | Unlimited | Unlimited |
| Survey responses a month | 10 | Unlimited | Unlimited |
| Themes with AI | No | No | Yes |

Both limits count across the whole workspace, not per site. At the monthly
response limit the survey **stops being offered**, so nobody is shown a
card whose answer we would throw away, and nothing is stored past the limit
(the allowance is checked again when the answer is written). The count
resets on the 1st of the month.

**Survey responses are not events.** They never count toward your
[event allowance](/docs/billing/usage), and neither do the module's own two
requests, or API and MCP calls.

## Privacy signals and opt-out

- **Do Not Track** and **Global Privacy Control** are honored according to
  your site's settings (**Site settings → Privacy**, where GPC is on by
  default). When you honor a signal and the browser sends it, no survey is
  offered and no answer is stored. See [DNT and GPC](/docs/privacy/dnt-gpc).
- A visitor who has [opted out](/docs/tracker/opt-out) is not asked. The
  module stops before it asks us, and again before it shows a card.
- A page your tracking code excludes with `data-exclude` is not asked, and
  while a workspace is over its monthly event limit and collection is
  paused, no survey is offered.

## What is stored

One **response** row per submission:

| Field | What it holds |
|---|---|
| Survey, site, workspace | Which survey the answer belongs to |
| Finished | Whether the last question was answered, or the card was closed part way |
| Path | The page it was answered on, with your path masks applied and the query string already removed, up to 2,048 characters |
| Device | `desktop`, `mobile` or `tablet`, up to 16 characters |
| Country | The two-letter code from Cloudflare's `CF-IPCountry` header. No region or city |
| Time | When it arrived |

One **answer** row per answered question (one per picked choice for a
several-choices question):

| Field | What it holds |
|---|---|
| Response, survey | Which submission it belongs to |
| Question | The question's id, so a reworded prompt keeps its answers |
| Text value | A choice label, `yes` or `no`, or the scrubbed free text |
| Number value | A rating or an NPS score |
| Time | When it arrived |

That is the whole record. There is no visitor id, no IP address, no raw
User-Agent, no referrer, no screen size and no name or email address. See
the [data inventory](/docs/privacy/data-inventory) for everything else we
store.

## API and MCP

Surveys are REST resources and the MCP tools `surveys_list`,
`surveys_get`, `surveys_create`, `surveys_update`, `surveys_delete`,
`surveys_start`, `surveys_pause` and `surveys_themes`. `surveys_get`
returns the report for a period, and `surveys_themes` generates the themes
for one question (Business). The [API reference](/docs/api) lists every
input.

```sh
curl -X POST https://privatusanalytics.com/sites/pa_7Q2K9XH3AB/surveys.json \
  -H "Authorization: Bearer $PRIVATUS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Pricing page", "status": "active",
       "trigger_kind": "delay", "delay_seconds": 15,
       "questions": [{"kind": "choice", "required": true,
                      "prompt": "Is anything unclear about our pricing?",
                      "choices": ["I cannot tell which plan I need",
                                  "It costs more than I expected",
                                  "No, it is clear"]}],
       "paths": [{"match_type": "prefix", "pattern": "/pricing"}]}'
```

A survey created over the API is a **Draft** unless you send
`"status": "active"`.

## Ask from your own code

Give a survey the **When you ask for it** trigger and show it yourself,
after a failed search or a canceled signup:

```js
privatus.ask('svy_ABC123')
```

It shows the survey we offered for this page, and returns `true` when there
was one to show. It returns `false` when nothing was offered (no survey
matched, the visitor was asked already today, or they have opted out) and
when the id you pass is not the one offered, so your own code can never
show a survey that targeting ruled out. Call it with no argument to show
whatever was offered. A card that has already been shown and closed on this
page is not shown again.

The offer is fetched when the page loads, so a call in the first instants
of a page load can return `false` before the answer arrives.
