# Run in-product surveys

> Ask the visitors of your product a few questions, targeted by page, audience, feature flag, sample and schedule. Your agent defines and launches the survey, every answer is recorded as an event on the person, and the results open as a proof page with the people and the open-text answers behind every count.

Canonical page: https://anectico.com/docs/investigate/surveys/


An in-product survey is a short set of questions your own product shows to the visitors it
targets: a rating, a choice, a sentence of free text. Your agent defines it once through MCP or the
CLI and launches it. Then the [browser SDK](/docs/reference/javascript-sdk) (or your own code) asks
the platform which survey a visitor may see **now**, shows it, and reports what happened.

Three things make it different from a form builder:

- **Targeting is decided on the server.** The browser is told which surveys one visitor may see,
  and nothing about why: no audience, flag, sample or response cap ever reaches a page.
- **Every outcome is an event on the person.** A display, a response and a dismissal are recorded
  as `$survey_shown`, `$survey_response` and `$survey_dismissed`, so they appear on the person's
  timeline and you can use them in any trend, funnel or [audience](/docs/investigate/events-and-cohorts).
- **Results are exact counts, with the people behind every one.** The platform returns how many
  people were shown, answered or dismissed a survey and what they chose. Rates and scores are
  arithmetic on those counts, shown labelled as derived.

Results are one kind of the [typed product query](/docs/investigate/product-analytics): `survey`.
Like every other kind a result is measured once, frozen under an execution key, and read again by
result ID. You open a result as a read-only proof page.

## Ask your agent

> "Draft a two-question survey for the billing page: a 0 to 10 recommend score and an optional
> 'why'. Offer it to half of the visitors, and not to anyone who saw a survey in the last 30 days.
> Show me the definition first."

> "Launch it. Then, a week later, tell me the response rate and the NPS, and send me the proof link."

| Job | MCP tool or action | CLI command |
| --- | --- | --- |
| Read the bounds every survey is held to, and the refusal codes | `get_survey_limits` | `anectico surveys limits` |
| List a project's surveys, or read one | `list_surveys`, `get_survey` | `anectico surveys list`, `anectico surveys get` |
| Create a draft | `create_survey` | `anectico surveys create` |
| Replace a draft's whole definition | `update_survey` | `anectico surveys update` |
| Copy a survey into a new draft | `duplicate_survey` | `anectico surveys duplicate` |
| Launch a draft (previewed first, then confirmed) | `launch_survey` | `anectico surveys launch` |
| Stop an active survey | `stop_survey` | `anectico surveys stop` |
| Delete a draft or a stopped survey | `delete_survey` | `anectico surveys delete` |
| Measure the results of a launched survey | `query_product_analytics` | `anectico analytics query` |
| Read a stored result again | `get_analytics_result` | `anectico analytics result get` |
| Page the open-text answers of one question | `list_analytics_survey_answers` | `anectico analytics result survey-answers` |
| Page the people behind a count | `list_analytics_participants` | `anectico analytics result participants` |

The reads are MCP read actions: your agent finds them with `list_read_actions` and runs them with
`execute_read_action`. The writes are write actions: it finds them with `list_write_actions` and
runs them with `execute_internal_action`. Launch, stop and delete are previewed first and applied
only with the returned confirmation token, so an agent cannot put a survey in front of real people
in one step. Ask your agent to show you the preview before it launches: real people see a launched
survey within about 15 seconds. The scopes for each job are in [Permissions](#permissions).

## What your agent gets back

- From the management calls: the survey, with its `id`, `state`, `revision`, `definition`,
  `response_count` and timestamps (see the example in
  [Create, launch, stop and read](#create-launch-stop-and-read-complete-examples)). These calls
  carry no proof link: a survey's definition and targeting have no proof page, so your agent reads
  them with `get_survey` and reports them in its answer.
- From a result query: the frozen `survey` result, with `state`, `closed`, `totals`, one section
  per question, `accounting`, and a `selection_id` behind every count (see
  [The shape of a result](#the-shape-of-a-result)). Over MCP, the result carries a link to the
  proof page in its `links`, in the entry titled "Analytics result" (the address is its `href`).
  Every result tool in the table carries the same link. The CLI prints the result and no link.
- From the answers call: one page of open-text answers, each with its respondent (see
  [Open-text answers](#open-text-answers)).

## Open the proof

Open the link from your agent. It opens the stored result in the read-only result viewer, with the
project named in the page header. See [Proof pages](/docs/agents/proof-pages) and
[Result viewer](/docs/agents/result-viewer).

The page never measures again. It shows what was stored, when it was measured (in UTC) and when the
stored result expires. Its one action is **Copy link**. To look at another window, ask your agent to
measure again with a new execution key.

You see:

- tiles for **Shown**, **Responded** and **Dismissed**, and a **Response rate (derived)** tile with
  its denominator (`4 responses ÷ 5 displays`, or a dash when nobody was shown the survey);
- for each **choice** question, a table with every option's responses, its **share of answers
  (derived)** and a bar beside it;
- for each **rating** question, the whole scale with each value's responses and share, and on a 0
  to 10 scale the **promoters, passives and detractors** with a **Net promoter score (derived)** and
  the arithmetic behind it;
- for each **open-text** question, the answers as a list, 20 at a time with **Load more answers**,
  each with its time and a link to the respondent's person proof page, and a note that personal
  detector matches in the captured text are redacted;
- the state in words above the figures: **Still measuring** and **Insufficient coverage** show
  their figures labelled, and **Coverage unavailable** shows none and says it is not a result of
  zero.

If the person who opens the link lacks the event-content permission, the page says so and names
`agents:content:read`.

## Questions

A survey has **one to ten questions**, shown in the order you give. Each question has a stable
`id`, a `prompt` the visitor reads, a `required` flag and one type:

| Type (`type`) | The visitor | Declares | An answer carries |
| --- | --- | --- | --- |
| `SURVEY_QUESTION_TYPE_SINGLE_CHOICE` | picks exactly one option | 2 to 12 `options`, each `{id, label}` | one option id |
| `SURVEY_QUESTION_TYPE_MULTIPLE_CHOICE` | picks one or more options | 2 to 12 `options` | one or more distinct option ids |
| `SURVEY_QUESTION_TYPE_RATING` | picks a whole number on a scale | a `scale` of `{min, max, low_label, high_label}`: `min` is 0 or 1, `max` is above `min` and at most 10, the two labels are optional | a number inside the scale |
| `SURVEY_QUESTION_TYPE_OPEN_TEXT` | types free text | nothing | 1 to 2,000 characters |

A rating question on a **0 to 10 scale is the NPS scale**: its result also reports promoters,
passives and detractors.

**Ids are how results count.** A question id and an option id are slugs: 1 to 32 characters, a
lowercase letter followed by lowercase letters, digits or underscores (`nps`, `plan`, `free`). A
response names the question and the option it answers by these ids, and a result joins them back to
the definition, so they are frozen from launch along with everything else. An id that looks like
sensitive data, such as a card number, is refused, because it would be redacted from the events it
travels in. The definition carries every id. Ids can change while the survey is a draft and never
after launch.

A required question must be answered for a response to be accepted. A response that answers a
question the survey does not have, names an option it does not have, or goes outside a scale is
refused whole and records nothing.

## The lifecycle, and what is frozen

A survey is in one of three states, shown by the `state` field:

| State | Meaning |
| --- | --- |
| `SURVEY_STATE_DRAFT` | Never shown to anyone. You can edit it freely. |
| `SURVEY_STATE_ACTIVE` | Launched. Offered to the visitors it targets. Nothing can be edited. |
| `SURVEY_STATE_STOPPED` | Stopped by a person. Never offered again. Its results stay readable. |

The steps between them:

- **Create** makes a draft at `revision` `1`. A project holds at most **200 surveys** of every
  state.
- **Update** replaces a draft's whole definition and **compares and swaps on the revision** you
  read: pass `expected_revision`. A stale revision, or a survey that is no longer a draft, is
  refused with `SURVEY_REVISION_CONFLICT` and a `409`. Each draft edit advances the revision.
- **Launch** also compares and swaps on the draft revision. It checks, in one step, that the survey
  has at least one question (`SURVEY_INCOMPLETE`), that its `end_at` is still in the future
  (`SURVEY_SCHEDULE_ENDED`), that its audience and feature flag exist in the project
  (`SURVEY_AUDIENCE_UNAVAILABLE`, `SURVEY_FLAG_NOT_FOUND`) and that the project runs fewer than
  **20 active surveys** (`SURVEY_LIMIT`).
- **Stop** is permanent and safe to repeat: stopping a stopped survey returns it unchanged. Take an
  optional `reason` of at most 1,024 characters.
- **Duplicate** copies any survey's definition into a new draft, keeping its question and option
  ids and clearing its schedule. It is how a stopped survey runs again. The copy is named
  `<name> (copy)` unless you give a name.
- **Delete** removes a draft or a stopped survey together with the record of who was shown it. An
  active survey is refused with `SURVEY_ACTIVE`: stop it first. The events it already recorded
  stay in your data, but **a deleted survey's results can no longer be computed**: the definition
  is what gives those events their meaning.

From launch, the questions, options, scale, targeting and revision are fixed, so every recorded
answer refers to exactly one definition. To change a launched survey, stop it, duplicate it and edit
the copy. There is no editing after launch.

## Who is offered a survey

Targeting decides who is **offered** a survey. Every condition is optional, and **every condition
you set must hold**: they combine with AND. A condition you leave empty narrows nobody out.

| Field (`targeting`) | Condition |
| --- | --- |
| `routes` | Up to 20 route templates, each at most 512 characters, matched **exactly** against the `$pathname` the SDK reports (a template such as `/orders/:id`, not a URL or a query string). Empty means every page. |
| `audience` | `{cohort_id, generation}`. The person must be a member of the [audience](/docs/investigate/events-and-cohorts). Generation `0`, or omitted, is the audience's current membership; a higher number is that exact saved generation. Your agent finds the cohort ID with `list_cohorts`. |
| `feature_flag` | `{key, variant}`. The [feature flag](/docs/investigate/rollout-health) must be on for the visitor. With a `variant`, the visitor must receive exactly that variant. |
| `sample_percent` | 1 to 100; 0 or omitted means everyone. A visitor's place in the sample is **stable for a survey** and independent between surveys, so raising the percentage keeps the people already in. |
| `max_responses` | Stop offering the survey, and stop accepting responses, once this many were accepted. 0 means no cap. The cap is exact: a survey never accepts more. |
| `start_at`, `end_at` | RFC 3339 instants. The survey is offered, and accepts responses, only inside `[start_at, end_at)`. Either may be absent. |
| `wait_days_after_any_survey` | 0 to 365. A person who was shown **any** survey of the project in the last N days is not offered this one. |

Two rules apply on top of the conditions:

- **Once per person.** A survey is offered to a person at most once. From the first recorded
  display it is never returned for that person again, whether they then answer it, dismiss it or
  ignore it. They may still answer or dismiss the survey they were shown, which is why an SDK holds
  a displayed survey until it has an outcome.
- **At most three at a time.** One request for a visitor returns at most three surveys, oldest
  launch first. The rest are offered on a later request.

**An unresolved visitor.** A visitor whose identifier Anectico has not resolved belongs to
no audience: **a survey with an audience condition is never offered to them**. Every other
condition works on the distinct ID the page reports. The sample is decided from that ID. The
once-per-person rule and the cooldown are keyed by the person once the platform knows them, and by
that ID until then, and a visitor who answers anonymously and then signs in is still recognized as
having answered.

An unmapped declaration stays unresolved even if it spells another person's
person ID. Their answers remain in the unresolved population and never reuse
that person's survey subject. A resolved anonymous person is different from an
unresolved visitor. Attribution is as reliable as the application's own customer
identification; an intake credential does not authenticate each end user.

**A condition that cannot be evaluated is not met.** If the audience or the flags cannot be read
at that moment, the survey is left out and the others are still answered, so an outage never shows
a survey to someone outside its audience.

**How long a launch or a stop takes.** A launch, a stop or a reached response cap changes what
every server offers within about **15 seconds** (`eligibility_staleness_seconds` in the
[limits](#limits) reports the value in force). The cap itself is not delayed: the survey never
accepts more responses than `max_responses`, even while a server still offers it for a moment.

**Erasing a person takes their response back.** When [a person is
erased](/docs/manage/erase-a-person), the record that they were offered, shown, answered or
dismissed a survey is removed, their answers are removed with the rest of their events, and the
survey's `response_count` is lowered by the responses they gave, so the count matches the results.
A capped survey has that slot again. The erased identifiers are never offered a survey again, and
a display, a response or a dismissal sent under one is answered as it is for any visitor the survey
was not offered to, with `SURVEY_NOT_OFFERED`, and records nothing. These calls are made with the
key in your page, so they do not say that an identifier was erased.

## How responses become events

Each outcome is recorded as a product event on the person, with the origin `platform`. They appear
on the person's timeline and in your event catalog, and they work in trends, funnels and audiences
like any other event.

| Event | Recorded | Properties |
| --- | --- | --- |
| `$survey_shown` | once per person, when the survey was displayed | `$survey_id`, `$survey_revision` |
| `$survey_dismissed` | once, when the person closed the survey without answering | `$survey_id`, `$survey_revision` |
| `$survey_response` | once per answered question, and once per chosen option of a multiple-choice question | `$survey_id`, `$survey_revision`, `$survey_response_id`, `$survey_question_id`, `$survey_question_type`, and one of `$survey_choice_id`, `$survey_rating` or `$survey_text` |

- `$survey_question_type` is `single_choice`, `multiple_choice`, `rating` or `open_text`.
- `$survey_rating` is a number. `$survey_choice_id` is the option's id. `$survey_text` is the free
  text, with line breaks and tabs stored as single spaces.
- `$survey_response_id` groups the events of **one response** without storing the id your page sent.
- Each event also carries `$environment` when the delivery call named one.
- A redelivered event is stored once: retrying a delivery call never double counts.

To measure them yourself, count `$survey_response` events in a [trend](/docs/investigate/product-analytics)
with `$survey_question_id` equal to `nps` and `$survey_rating` at least 9, or build an audience of
the people who dismissed a survey.

**The events appear shortly after the call returns, not at it.** They are handed to capture in the
background, so a response is measurable a few seconds after your page received its acknowledgement.

**Query reads return capture-redacted text.** Every `$survey_text` value passes through
sensitive-data detection during capture. A detector match, such as an email address, becomes
`[REDACTED]` in the captured event. A response waiting for capture can retain its original answer
text. A sensitive value the detectors miss can remain. Answer text is customer content, so reading
it needs the [event-content permission](#permissions).

Survey results read **only the events the platform itself recorded**. An event named `$survey_response` that your own code
sends cannot change a result, and you should not send these names.

## Show a survey

### In the browser

Use the browser SDK. It asks for the visitor's eligible surveys, displays them, calls the delivery
routes below, and holds a survey it displayed until it has an outcome. See the Surveys section of the
[JavaScript SDK reference](/docs/reference/javascript-sdk) for setup and the display options.

### Through REST, for your own interface or another platform

If you render the question yourself, or you are not in a browser, call the four delivery routes.
They are `POST` with a JSON body, authenticated and rate limited like `POST /api/v1/decide`, and need
an API key holding **`surveys:respond`** (see [permissions](#permissions)). The project is the
key's own project. Revisions are decimal strings in responses and accepted as strings or numbers in
requests. Enum values are full proto names.

**Which surveys may this visitor see now.** `POST /api/v1/surveys/eligible`:

```json
{"distinct_id": "u-1", "pathname": "/billing", "groups": {"company": "acme"}}
```

```json
{
  "surveys": [
    {
      "id": "SURVEY_UUID",
      "revision": "2",
      "questions": [
        {"id": "nps", "type": "SURVEY_QUESTION_TYPE_RATING", "prompt": "How likely are you to recommend us?", "required": true,
         "scale": {"min": 0, "max": 10, "low_label": "Not likely", "high_label": "Very likely"}},
        {"id": "plan", "type": "SURVEY_QUESTION_TYPE_SINGLE_CHOICE", "prompt": "Which plan are you on?", "required": true,
         "options": [{"id": "free", "label": "Free"}, {"id": "paid", "label": "Paid"}]}
      ]
    }
  ]
}
```

The answer carries questions and **nothing about targeting**. `groups` names the visitor's group keys
by group type, as for `decide`, and is used by flag conditions. The response is never cached.

**It was displayed.** `POST /api/v1/surveys/{surveyId}/shown`:

```json
{"revision": "2", "distinct_id": "u-1", "shown_id": "CLIENT_ID_1", "environment": "production"}
```

```json
{"shown_at": "2026-10-04T10:15:00Z", "replayed": false}
```

**They answered.** `POST /api/v1/surveys/{surveyId}/responses`, with one answer per answered
question: `rating` for a rating, `option_ids` for a choice, `text` for open text:

```json
{
  "revision": "2",
  "distinct_id": "u-1",
  "response_id": "CLIENT_ID_2",
  "environment": "production",
  "answers": [
    {"question_id": "nps", "rating": 9},
    {"question_id": "plan", "option_ids": ["paid"]},
    {"question_id": "why", "text": "Reports save me an hour a week"}
  ]
}
```

```json
{"responded_at": "2026-10-04T10:15:20Z", "replayed": false}
```

**They closed it.** `POST /api/v1/surveys/{surveyId}/dismissals`:

```json
{"revision": "2", "distinct_id": "u-1", "dismissal_id": "CLIENT_ID_3", "environment": "production"}
```

```json
{"dismissed_at": "2026-10-04T10:15:12Z", "replayed": false}
```

Rules for a client of these routes:

- **Echo the `revision`** from the eligibility answer on every call. Another revision is refused with
  `SURVEY_REVISION_MISMATCH`.
- **Generate each idempotency id once** (`shown_id`, `response_id`, `dismissal_id`: 1 to 128
  printable ASCII characters without spaces) and **reuse it on a retry**. A repeat with the same id
  is acknowledged with `replayed: true` and the original instant.
- **Call `shown` when the survey is actually displayed.** A second display for the same person is
  acknowledged with `replayed: true` whatever its id: one person has one display.
- **Read eligibility first.** A display, a response or a dismissal is accepted only for a visitor the
  eligibility answer returned this survey to. Anything else is refused with `SURVEY_NOT_OFFERED`,
  before the answers are looked at.
- A response or a dismissal with **no earlier display** is accepted for such a visitor, and records
  the display at the same instant, so a page that lost its `shown` call does not lose the answer. No `$survey_shown`
  event is recorded for it, so a result can then report more responses than displays.
- A different outcome from the same person is refused with `SURVEY_ALREADY_ANSWERED`. A response
  above the cap is refused with `SURVEY_RESPONSE_CAP_REACHED`.
- Treat any refusal whose message begins with `SURVEY_NOT_ACTIVE`, `SURVEY_REVISION_MISMATCH`,
  `SURVEY_ALREADY_ANSWERED`, `SURVEY_RESPONSE_CAP_REACHED` or `SURVEY_NOT_OFFERED` as **"stop
  showing this survey"**, not as an error to retry.
- A delivery call checks that the survey is active, inside its schedule and at the named revision,
  and that the eligibility answer offered it to this visitor. It does not evaluate the route,
  audience, flag or sample a second time: the offer already did, and the offer is what is required.

**What the key on your page can and cannot do.** The key that records displays and answers is in
your page, so anyone who reads the page has it. Because a delivery needs the server's own offer, a
person holding the key cannot answer a survey as a visitor the server would not have offered it to:
a made-up visitor is not in your audience, so an audience-targeted survey cannot be filled with
made-up answers. What remains possible is to act as a visitor the server **would** offer the survey
to. For a survey with no audience and no flag that is anyone, so made-up visitors can still answer
it and use up its response cap; a sampled survey is offered to a made-up visitor with the sample's
probability. If the answers must come from known people, target an audience.

These four routes have no CLI command and no MCP tool: each is about one visitor of your own product,
and an operator or an agent has no visitor to ask on behalf of.

## Read the results

### Run it

A survey result is a product query with a `survey` object:

```json
{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-10-03T00:00:00Z", "end": "2026-10-10T00:00:00Z"}},
  "survey": {"survey_id": "SURVEY_UUID"}
}
```

`survey_id` is a launched survey of the project. An optional `question_id` returns only that
question's section; the three totals are always the whole survey's. The definition names no
revision: a result freezes the survey's launched revision, which never changes. The counted unit is
the person. Person filters and audience restrictions are accepted and keep every response whole. A
`breakdown`, a `comparison` window, a filter group, account filters and any filter that is not a
person filter are refused with `QUERY_SURVEY_INVALID`, and the account unit with
`QUERY_UNIT_UNSUPPORTED`.

### The shape of a result

The result comes back under **`survey`**, and only when `status` is `PRODUCT_RESULT_STATUS_READY`.

- `state` says how complete it is ([below](#result-states)); `closed` says whether the window had
  ended at the observation boundary.
- `survey` is the frozen launched survey: `survey_id`, `revision`, `name`, `lifecycle_state`
  (`SURVEY_STATE_ACTIVE` or `SURVEY_STATE_STOPPED`), `launched_at` and `stopped_at`.
- `totals` holds three counts: `shown`, `responded` and `dismissed`.
- `questions` lists the launched questions in order. Each has `question_id`, `type`, `prompt`,
  `required` and `answered`, and one of:
  - `options`: for a choice question, **every declared option**, a real zero included, each with
    its `label` and a `chosen` count;
  - `rating`: `min`, `max`, the labels, `values` (**every value of the scale**, zeros included),
    `nps_scale`, and on a 0 to 10 scale `promoters` (ratings 9 and 10), `passives` (7 and 8) and
    `detractors` (0 to 6), which partition the answers;
  - `text`: how many `answers` there are and the `answer_selection_id` that pages them. **No answer
    text is ever part of a result.**
- `accounting` says how many survey outcomes were read (`occurrences`), how many match the
  launched questions, options and scale values (`recognized`) and how many do not (`unrecognized`).
  An outcome that names a question, option or rating the launched revision does not declare is
  counted here and in no question.
- `selections` and `answer_selections` register every selection the counts refer to.

**Every figure is a count**: `{count, people, unresolved, selection_id}`.

| Field | Meaning |
| --- | --- |
| `count` | Occurrences: displays for `shown`, responses for `responded` and every answer figure, dismissals for `dismissed`. A multiple-choice response is one response in its question however many options it chose, and one in each option it chose. |
| `people` | The distinct resolved **people** behind `count`. |
| `unresolved` | The distinct **unresolved identities** behind `count`, kept apart. They are never added into `people`. |
| `selection_id` | Pages exactly those people. Every figure declares one, a zero included. |

`count`, `people` and `unresolved` are allowed to differ: a person who answered under two identities
that were never merged is two subjects, and a person whose identities were merged after both answered
is one subject with two responses.

### Rates and scores are yours to derive

**The platform computes no rate, mean or score.** It returns exact numerators and denominators next
to each other, and you form the figure you want:

- **Response rate** = `totals.responded.count` ÷ `totals.shown.count`. With zero displays there is
  nothing to divide by: it is undefined, not 0%. It can exceed 100% when responses arrived with no
  recorded display.
- **Share of answers** for an option or a rating value = its `chosen.count` ÷ the question's
  `answered.count`. On a multiple-choice question the shares can add up to more than 100%.
- **Net promoter score** = the promoter share minus the detractor share, from −100 to +100:
  (`promoters.count` − `detractors.count`) ÷ `answered.count` × 100. With nobody answering it is
  undefined.

The proof page shows each of these labelled **derived**, with its denominator.

For the capture behind the examples on this page (five people shown a survey, four responded, one
dismissed, ratings 10, 9, 7 and 3), the response rate is 4 ÷ 5 = 80%, there are 2 promoters, 1 passive
and 1 detractor, and the NPS is (2 − 1) ÷ 4 = +25.

### A trimmed result

```json
{
  "status": "PRODUCT_RESULT_STATUS_READY",
  "survey": {
    "state": "PRODUCT_SURVEY_STATE_MEASURED",
    "closed": true,
    "survey": {
      "survey_id": "SURVEY_UUID", "revision": "1", "name": "Billing page pulse",
      "lifecycle_state": "SURVEY_STATE_ACTIVE", "launched_at": "2026-10-04T04:53:30.521Z", "stopped_at": null
    },
    "totals": {
      "shown": {"count": "5", "people": "5", "unresolved": "0", "selection_id": "SELECTION_UUID"},
      "responded": {"count": "4", "people": "4", "unresolved": "0", "selection_id": "SELECTION_UUID"},
      "dismissed": {"count": "1", "people": "1", "unresolved": "0", "selection_id": "SELECTION_UUID"}
    },
    "questions": [
      {
        "question_id": "nps", "type": "SURVEY_QUESTION_TYPE_RATING",
        "prompt": "How likely are you to recommend us?", "required": true,
        "answered": {"count": "4", "people": "4", "unresolved": "0", "selection_id": "SELECTION_UUID"},
        "rating": {
          "min": 0, "max": 10, "low_label": "Not likely", "high_label": "Very likely",
          "values": [{"value": 0, "chosen": {"count": "0", "people": "0", "unresolved": "0", "selection_id": "SELECTION_UUID"}}],
          "nps_scale": true,
          "promoters": {"count": "2", "people": "2", "unresolved": "0", "selection_id": "SELECTION_UUID"},
          "passives": {"count": "1", "people": "1", "unresolved": "0", "selection_id": "SELECTION_UUID"},
          "detractors": {"count": "1", "people": "1", "unresolved": "0", "selection_id": "SELECTION_UUID"}
        }
      },
      {
        "question_id": "plan", "type": "SURVEY_QUESTION_TYPE_SINGLE_CHOICE",
        "prompt": "Which plan are you on?", "required": true,
        "answered": {"count": "4", "people": "4", "unresolved": "0", "selection_id": "SELECTION_UUID"},
        "options": [
          {"option_id": "free", "label": "Free", "chosen": {"count": "2", "people": "2", "unresolved": "0", "selection_id": "SELECTION_UUID"}},
          {"option_id": "paid", "label": "Paid", "chosen": {"count": "2", "people": "2", "unresolved": "0", "selection_id": "SELECTION_UUID"}}
        ]
      },
      {
        "question_id": "why", "type": "SURVEY_QUESTION_TYPE_OPEN_TEXT",
        "prompt": "Why that score?", "required": false,
        "answered": {"count": "3", "people": "3", "unresolved": "0", "selection_id": "SELECTION_UUID"},
        "text": {"answers": "3", "answer_selection_id": "ANSWER_SELECTION_UUID"}
      }
    ],
    "accounting": {"occurrences": "17", "recognized": "17", "unrecognized": "0"}
  }
}
```

(`values` is shortened here; a real result lists the whole scale.)

### Open-text answers

A text question's section carries `text.answer_selection_id`. Page the answers with it:

```text
GET /api/v1/analytics/results/{result_id}/survey-answers?project_id=PROJECT_UUID&selection_id=ANSWER_SELECTION_UUID&limit=20&cursor=
```

```json
{
  "result_id": "RESULT_UUID",
  "selection_id": "ANSWER_SELECTION_UUID",
  "question_id": "why",
  "answers": [
    {"ordinal": "1", "respondent": {"person_id": "PERSON_UUID"}, "text": "Love it, [REDACTED]",
     "answered_at": "2026-10-04T04:53:35.436Z", "response_id": "RESPONSE_ID"},
    {"ordinal": "2", "respondent": {"person_id": "PERSON_UUID"}, "text": "It is fine",
     "answered_at": "2026-10-04T04:53:35.478Z", "response_id": "RESPONSE_ID"}
  ],
  "has_more": true,
  "next_cursor": "CURSOR",
  "total": "3"
}
```

- `ordinal` is one-based and fixed when the result was published, in the order the answers were given.
- `respondent` is a `person_id`, or an `unresolved_distinct_id` for an identity that has no person.
- `response_id` groups the answers one response gave. `total` is the number of answers.
- A page holds **1 to 50 answers**; REST, MCP and the CLI send 20 when you do not say. Continue
  with `next_cursor` and keep the result, selection and `limit` unchanged.
- The page is read from the frozen result, never measured again, and **expires with the result**.
- The `text` is the captured answer. Sensitive-data detector matches are `[REDACTED]`; this does
  not prove that every sensitive value was detected or that no original was retained before capture.

### The people behind every count

Every count's `selection_id` pages the people behind it with the same call as any other kind:

```bash
anectico --project PROJECT_UUID analytics result participants RESULT_UUID \
  --selection-id SELECTION_UUID --limit 50
```

MCP's `list_analytics_participants` and `GET /api/v1/analytics/results/{result_id}/participants`
take the same `selection_id`, `limit` and `cursor`. A survey selection lists resolved people **and**
unresolved identities together, and each can be exported.

### Result states

| `state` | Meaning | Figures |
| --- | --- | --- |
| `PRODUCT_SURVEY_STATE_MEASURED` | The window ended and capture coverage is sufficient. | Complete. |
| `PRODUCT_SURVEY_STATE_IMMATURE` | The window had not ended at the observation boundary. | Disclosed, labelled: what was observed so far, which may grow. |
| `PRODUCT_SURVEY_STATE_INSUFFICIENT_EVIDENCE` | The window ended but capture coverage is not sufficient: for example, accepted events were still being stored. | Disclosed, labelled: they may undercount. |
| `PRODUCT_SURVEY_STATE_UNAVAILABLE` | Coverage is unavailable. | **Nothing is disclosed.** It is never a result of zero. |

`manifest.current.coverage` says why: its `reason` is `PRODUCT_COVERAGE_REASON_LAG` when events were
still arriving (with `pending_occurrences` saying how many) and `PRODUCT_COVERAGE_REASON_GAP` for a
known gap. The proof page states the state in words above the figures.

## Permissions

Three scopes control surveys:

| Scope | Grants |
| --- | --- |
| `surveys:read` | Read surveys: their definitions, targeting, state and accepted-response count, and the limits. |
| `surveys:write` | Create, edit a draft, launch, stop, duplicate and delete. Every write also requires `surveys:read`. |
| `surveys:respond` | The **SDK surface only**: ask for eligible surveys and record a display, a response or a dismissal. |

`surveys:respond` implies neither of the others, and neither of them implies it.

> **Warning.** The API key embedded in your page must hold **only** `surveys:respond`. A key in a
> page is visible to every visitor, so it must not be able to read a survey's targeting or change a
> survey. A management key has no visitor to answer for and cannot call the delivery routes.

**Every survey result also needs the event-content permission.** What a person answered is
customer-supplied content, so reading a result needs **`agents:content:read`** in addition to
`analytics:read`, `analytics:query` and `persons:read`: when the result is measured, and on every
later read of it, of the people behind a count and of the open-text answers. Without it the request is refused with
`403` and a message naming what is missing:

```json
{"error": "PermissionDenied", "message": "current analytics and source permissions are required"}
```

Anyone who can read a survey result can page the people behind its counts. See
[permissions](/docs/reference/permissions).

## Create, launch, stop and read: complete examples

### REST

Create a draft. The request is `{"definition": ...}`; the response is `201` with the draft at
revision `1`:

```bash
curl --fail-with-body -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/projects/$ANECTICO_PROJECT/surveys" \
  -d '{
    "definition": {
      "name": "Billing page pulse",
      "description": "Quarterly check on the billing page",
      "questions": [
        {"id": "nps", "type": "SURVEY_QUESTION_TYPE_RATING", "prompt": "How likely are you to recommend us?", "required": true,
         "scale": {"min": 0, "max": 10, "low_label": "Not likely", "high_label": "Very likely"}},
        {"id": "plan", "type": "SURVEY_QUESTION_TYPE_SINGLE_CHOICE", "prompt": "Which plan are you on?", "required": true,
         "options": [{"id": "free", "label": "Free"}, {"id": "paid", "label": "Paid"}]},
        {"id": "why", "type": "SURVEY_QUESTION_TYPE_OPEN_TEXT", "prompt": "Why that score?", "required": false}
      ],
      "targeting": {"routes": ["/billing"], "sample_percent": 50, "wait_days_after_any_survey": 30}
    }
  }'
```

```json
{
  "survey": {
    "id": "SURVEY_UUID",
    "org_id": "ORG_UUID",
    "project_id": "PROJECT_UUID",
    "state": "SURVEY_STATE_DRAFT",
    "revision": "1",
    "definition": {"name": "Billing page pulse", "questions": ["..."], "targeting": {"routes": ["/billing"], "sample_percent": 50}},
    "response_count": "0",
    "created_at": "2026-10-04T04:50:00Z",
    "updated_at": "2026-10-04T04:50:00Z",
    "launched_at": null,
    "stopped_at": null,
    "stop_reason": ""
  }
}
```

Edit the draft at the revision you read, replacing the whole definition:
`PUT .../surveys/SURVEY_UUID` with `{"expected_revision": "1", "definition": {...}}`. Read one survey
with `GET .../surveys/SURVEY_UUID`, list them with `GET .../surveys?state=draft&limit=50&cursor=`
(`state` is `draft`, `active` or `stopped`; `limit` is 1 to 200) and read the bounds with
`GET /api/v1/surveys/limits`.

Launch it at the revision you read:

```bash
curl --fail-with-body -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/projects/$ANECTICO_PROJECT/surveys/$SURVEY/launch" \
  -d '{"expected_revision": "1"}'
```

The response is `{"survey": {...}}` with `"state": "SURVEY_STATE_ACTIVE"`. Stop it, with an optional
reason (the body may be omitted):

```bash
curl --fail-with-body -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/projects/$ANECTICO_PROJECT/surveys/$SURVEY/stop" \
  -d '{"reason": "Enough answers"}'
```

Duplicate with `POST .../surveys/SURVEY_UUID/duplicate` (optional `{"name": "..."}`; `201` with the new
draft) and delete with `DELETE .../surveys/SURVEY_UUID` (a draft or a stopped survey).

Read the results of the last week:

```bash
curl --fail-with-body -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/analytics/query" \
  -d '{
    "project_id": "'"$ANECTICO_PROJECT"'",
    "execution_key": "'"$(uuidgen | tr A-Z a-z)"'",
    "definition": {
      "version": 2,
      "timezone": "UTC",
      "window": {"relative": {"lookback_seconds": 604800}},
      "survey": {"survey_id": "'"$SURVEY"'"}
    }
  }'
```

The response is `{"survey": {...}, "status": "PRODUCT_RESULT_STATUS_READY", "result_id": "..."}`. A
measurement that is not ready yet returns its `status` and `result_id`; read it again with
`GET /api/v1/analytics/results/{result_id}?project_id=...` until it is ready.

### CLI

```bash
anectico --project PROJECT_UUID surveys limits
anectico --project PROJECT_UUID surveys create --file survey.json     # a SurveyDefinition
anectico --project PROJECT_UUID surveys list --state draft
anectico --project PROJECT_UUID surveys update SURVEY_UUID --expected-revision 1 --file survey.json
anectico --project PROJECT_UUID surveys launch SURVEY_UUID --expected-revision 1 --yes
anectico --project PROJECT_UUID surveys stop SURVEY_UUID --reason "Enough answers" --yes
anectico --project PROJECT_UUID surveys duplicate SURVEY_UUID --name "Billing page pulse, round two"
anectico --project PROJECT_UUID surveys delete SURVEY_UUID --yes
```

`launch`, `stop` and `delete` need `--yes`: real people will see a launched survey, and a stop or a
delete cannot be undone. Read results with the same command as every other kind:

```bash
anectico --project PROJECT_UUID analytics query --file survey-result.json --execution-key EXECUTION_UUID
anectico --project PROJECT_UUID analytics result survey-answers RESULT_UUID \
  --selection-id ANSWER_SELECTION_UUID --limit 20
```

where `survey-result.json` is the query definition shown above.

### MCP

The tools are listed in [Ask your agent](#ask-your-agent). `create_survey` takes `definition` (the
survey definition as a JSON string). `update_survey` takes `survey_id`, `expected_revision` and the
complete replacement `definition`. `launch_survey` takes `survey_id` and `expected_revision`,
`stop_survey` takes `survey_id` and an optional `reason`, and `delete_survey` takes `survey_id`.
Launch, stop and delete are previewed first and applied only with the returned confirmation token,
so an agent cannot put a survey in front of real people in one step. Write tools need an MCP key
with `mcp:write`, `surveys:read` and `surveys:write`. `list_analytics_survey_answers` takes the
result, selection, limit and cursor, like the REST route.

## Where a survey result is accepted

| Feature | Survey results |
| --- | --- |
| [Result export](/docs/manage/save-and-export-data) | Accepted: the people behind a count export like any other selection. A measurement carries counts only; answer text is read through the answers page and is not exported. |
| [Saved insights](/docs/investigate/saved-insights) and [dashboard widgets](/docs/investigate/service-health-and-dashboards) | The recipe is saved and shown as its own result, relative windows included. A widget shows **counts only**: never an answer text and never a person. |
| [Scheduled reports](/docs/investigate/scheduled-reports) | Accepted, **counts only**: the three outcomes as figures and one row per option, rating value, NPS group and text question (a text question's row is its answer count). A report never carries an answer text. Its owner needs `agents:content:read`. |
| [Metric watches](/docs/investigate/metric-watches) | Refused with `UNSUPPORTED_RESULT_SELECTOR`: a watch names no question or option. To watch survey activity, watch `$survey_response` through a trend. |
| [Live screens](/docs/investigate/live-screens) | Refused with `UNSUPPORTED_RESULT_SELECTOR`. |

## What is refused

A refused survey request returns a message that **begins with a stable code**:

| Code | Refused |
| --- | --- |
| `SURVEY_INVALID` | A malformed definition or request: a missing name, a bad id, a wrong field for a question's type, a repeated id, a route that does not start with `/`, a sample above 100, an end before the start. The message names the field and the rule, never a value you sent. |
| `SURVEY_LIMIT` | A definition or a project above a [bound](#limits): too many questions, options, routes, surveys or active surveys. |
| `SURVEY_REVISION_CONFLICT` | An edit or a launch of a survey that is not a draft at the revision you passed. Read it again. |
| `SURVEY_INCOMPLETE` | Launching a survey with no question. |
| `SURVEY_SCHEDULE_ENDED` | Launching a survey whose `end_at` has passed. |
| `SURVEY_AUDIENCE_UNAVAILABLE` | Launching a survey whose audience does not exist in the project. |
| `SURVEY_FLAG_NOT_FOUND` | Launching a survey whose feature flag does not exist in the project. |
| `SURVEY_ACTIVE` | Deleting an active survey. Stop it first. |
| `SURVEY_NOT_ACTIVE` | A delivery call for a survey that is not active or is outside its schedule, or stopping a draft. |
| `SURVEY_REVISION_MISMATCH` | A delivery call that names another revision than the launched one. |
| `SURVEY_ANSWER_INVALID` | A response that does not answer the launched questions: a missing required answer, an unknown question or option, a rating outside the scale, an empty or too long text. It records nothing. |
| `SURVEY_ALREADY_ANSWERED` | A second, different outcome from the same person. |
| `SURVEY_RESPONSE_CAP_REACHED` | A response above `max_responses`. |
| `SURVEY_NOT_OFFERED` | A display, response or dismissal for a visitor the eligibility answer did not return this survey to. Read the eligible surveys for the visitor first. It records nothing. |

`GET /api/v1/surveys/limits` returns the same list as `refusal_codes`. A result query has its own
codes, shown with the definition's `400`:

| Code | Refused |
| --- | --- |
| `QUERY_SURVEY_INVALID` | A missing or malformed `survey_id` or `question_id`, a `breakdown`, a `comparison` window, a filter group, account filters or a filter that is not a person filter. |
| `QUERY_SURVEY_NOT_FOUND` | A survey that does not exist in the project, was never launched, or a `question_id` the launched revision does not have. |

A saved survey definition whose survey was deleted afterwards fails with
`PRODUCT_RESULT_FAILURE_REASON_SURVEY_UNAVAILABLE`. Measuring it again cannot bring the survey back.

## Limits

| Limit | Value |
| --- | --- |
| Surveys in a project, in every state | 200 |
| Active surveys in a project | 20 |
| Questions in a survey | 1 to 10 to launch (a draft may have none) |
| Options in a choice question | 2 to 12 |
| Name | 200 characters |
| Description | 2,000 characters |
| Question prompt | 500 characters |
| Option label, rating end label | 200 characters |
| Open-text answer | 2,000 characters |
| Rating scale | starts at 0 or 1, ends at most 10 |
| Routes in targeting | 20, each at most 512 characters |
| Feature flag key, variant | 200 characters each |
| Wait after any survey | 0 to 365 days |
| Stop reason | 1,024 characters |
| Surveys returned for one visitor at once | 3 |
| Delay before a launch, stop or reached cap reaches every server | about 15 seconds |
| Idempotency id (`shown_id`, `response_id`, `dismissal_id`) | 1 to 128 printable ASCII characters, no spaces |
| Group keys in one eligibility request | 10 |
| Open-text answers in one page | 1 to 50 (20 by default) |
| People listed across all of a result's selections | 20,000,000 |

A result that would list more than 20,000,000 people across its selections fails with
`PRODUCT_RESULT_FAILURE_REASON_EXECUTION_BUDGET` instead of being cut short. The measurement limits
shared by every kind (measurements in flight, time to run, result lifetime) are in
[Limits](/docs/reference/limits). `anectico surveys limits` reports the survey bounds, and
`anectico analytics capabilities` reports the `survey` kind with the person unit and its
`max_survey_questions`, `max_survey_options`, `max_survey_participant_rows`,
`max_survey_answer_page` and `max_survey_text_characters` bounds.

## What surveys do not do

- **No conditional branching.** A survey asks its questions in the order you gave; an answer
  cannot skip or add a question.
- **No multi-language variants.** A survey has one text for each prompt and label.
- **No native mobile widget.** The browser SDK shows surveys in a page. The delivery routes above let a
  mobile app render its own interface, but the platform ships no widget for it.
- **No email or link surveys.** A survey is offered only inside your product, to a visitor the SDK or
  your code asks about. There is no shareable link and nothing is sent by email.
- **No editing after launch.** An active or stopped survey is frozen. Stop it, duplicate it and edit the copy.
- **No rates or scores computed for you.** The platform returns exact counts; the response rate,
  the shares and the NPS are derived from them.
- **No alerting on survey results.** A metric watch or a live screen cannot use a survey result.
