Skip to content
Console
Browse documentation
Guide

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.

On this page

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 (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.
  • 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: 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.

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). 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). 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 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 and 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. 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 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 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, 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 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.

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 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). 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:

{"distinct_id": "u-1", "pathname": "/billing", "groups": {"company": "acme"}}
{
  "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:

{"revision": "2", "distinct_id": "u-1", "shown_id": "CLIENT_ID_1", "environment": "production"}
{"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:

{
  "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"}
  ]
}
{"responded_at": "2026-10-04T10:15:20Z", "replayed": false}

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

{"revision": "2", "distinct_id": "u-1", "dismissal_id": "CLIENT_ID_3", "environment": "production"}
{"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:

{
  "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); 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

{
  "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:

GET /api/v1/analytics/results/{result_id}/survey-answers?project_id=PROJECT_UUID&selection_id=ANSWER_SELECTION_UUID&limit=20&cursor=
{
  "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:

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:

{"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.

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:

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}
    }
  }'
{
  "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:

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):

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:

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

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:

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. 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 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 and dashboard widgets 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 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 Refused with UNSUPPORTED_RESULT_SELECTOR: a watch names no question or option. To watch survey activity, watch $survey_response through a trend.
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: 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. 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.