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_responseand$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_countand 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 withget_surveyand reports them in its answer. - From a result query: the frozen
surveyresult, withstate,closed,totals, one section per question,accounting, and aselection_idbehind every count (see The shape of a result). Over MCP, the result carries a link to the proof page in itslinks, in the entry titled "Analytics result" (the address is itshref). 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
revision1. 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 withSURVEY_REVISION_CONFLICTand a409. 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 itsend_atis 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
reasonof 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_typeissingle_choice,multiple_choice,ratingoropen_text.$survey_ratingis a number.$survey_choice_idis the option's id.$survey_textis the free text, with line breaks and tabs stored as single spaces.$survey_response_idgroups the events of one response without storing the id your page sent.- Each event also carries
$environmentwhen 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
revisionfrom the eligibility answer on every call. Another revision is refused withSURVEY_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 withreplayed: trueand the original instant. - Call
shownwhen the survey is actually displayed. A second display for the same person is acknowledged withreplayed: truewhatever 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
showncall does not lose the answer. No$survey_shownevent 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 withSURVEY_RESPONSE_CAP_REACHED. - Treat any refusal whose message begins with
SURVEY_NOT_ACTIVE,SURVEY_REVISION_MISMATCH,SURVEY_ALREADY_ANSWERED,SURVEY_RESPONSE_CAP_REACHEDorSURVEY_NOT_OFFEREDas "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.
statesays how complete it is (below);closedsays whether the window had ended at the observation boundary.surveyis the frozen launched survey:survey_id,revision,name,lifecycle_state(SURVEY_STATE_ACTIVEorSURVEY_STATE_STOPPED),launched_atandstopped_at.totalsholds three counts:shown,respondedanddismissed.questionslists the launched questions in order. Each hasquestion_id,type,prompt,requiredandanswered, and one of:options: for a choice question, every declared option, a real zero included, each with itslabeland achosencount;rating:min,max, the labels,values(every value of the scale, zeros included),nps_scale, and on a 0 to 10 scalepromoters(ratings 9 and 10),passives(7 and 8) anddetractors(0 to 6), which partition the answers;text: how manyanswersthere are and theanswer_selection_idthat pages them. No answer text is ever part of a result.
accountingsays 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.selectionsandanswer_selectionsregister 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'sanswered.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"
}
ordinalis one-based and fixed when the result was published, in the order the answers were given.respondentis aperson_id, or anunresolved_distinct_idfor an identity that has no person.response_idgroups the answers one response gave.totalis 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_cursorand keep the result, selection andlimitunchanged. - The page is read from the frozen result, never measured again, and expires with the result.
- The
textis 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.