> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# NPS and surveys

> One question, asked at a moment you chose, of an audience you already defined — with a cooldown that belongs to the person rather than to the survey.

A survey is one question. What makes it usable in a product rather than annoying
is entirely in the arithmetic around it: who gets asked, when, how often anybody
can be asked at all, and what a repeated answer does.

## Two kinds

`nps` is the one question with a fixed 0–10 scale and fixed arithmetic — it is the
kind the results screen can compute a number from. `custom` is anything else, where
the verbatim is usually the answer.

The kind **cannot change after creation**: the scores already given were given on
the old scale.

## When it fires

A survey carries up to five triggers.

| Kind                 | Fires                                                                                                         |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `event`              | The first time one of the facts this system publishes about a contact is true of them                         |
| `segment_entered`    | When somebody enters **this survey's own audience** — so it requires `segment_id`, and is refused without one |
| `days_after_created` | N days after the contact existed                                                                              |

The first two are matched by a consumer as facts land, in seconds. The third is
**swept**, because nothing is published when somebody's thirtieth day arrives —
there is no fact, since nothing happened.

`days_after_created` only counts anniversaries that fall **after the survey was
written**. Switching on "ask on day 30" does not question your entire user base at
once, which is what a naive implementation does on its first run and cannot be
taken back.

**A person is asked once per occasion, ever**: the first time that fact is true of
them, the first time they enter that audience, their Nth day. Re-entering a segment
is not a new occasion — leaving and coming back is churn noise rather than a moment.

A **recurring** survey ("ask everybody their NPS every quarter") does not exist yet,
and a long cooldown deliberately does not impersonate one. It is a fourth trigger
kind with a period in its identity, and until it exists the honest answer is that
this cannot be asked.

## The cooldown belongs to the person

`cooldown_days` (default 7) is how long a delivery closes the door on **any**
survey for that contact — not on that survey.

That is the whole promise. "Nobody is surveyed twice a week" is not true of a
per-survey window: three surveys each asking at most once a day would all be inside
their own limit and the person would be asked three times. `0` is allowed and means
no cooldown, which is a real answer for an environment running exactly one survey.

Claiming a delivery is **one statement**, and its row count is the decision. The
audience, the identification, the activity and the window are all checked inside
one insert, because a select followed by an insert is two snapshots of one table —
and two deliveries racing on the same contact would both find nothing outstanding
and both write.

## The widget's read

```ts theme={null}
const surveys = await userkit.listPendingSurveys();
await userkit.answerSurvey(id, { score: 9, comment: "…" });
```

```tsx theme={null}
import { Survey } from "@userkit/react";

<Survey />;                          {/* centred modal — the default */}
<Survey position="bottom-right" />;  {/* a card in the corner, no backdrop */}
<Survey position="inline" />;        {/* in the page's flow */}
```

### Where the card is drawn

`position` takes `center` (default), `bottom-right`, `bottom-center`, `bottom-left`
or `inline`.

**Only `center` takes the page.** It draws a backdrop, moves focus into the card, and
closes on Escape or on a click outside. The two corners deliberately do not dim
anything: a question in the corner is one somebody may finish their sentence before
answering, and dimming the product to ask about the product is what makes a
five-second NPS feel like a paywall. They are announced to a screen reader as a
dialog but not as a modal one, because the rest of your product really is still
available.

`inline` is the component in your own layout, which is what it did before there was a
choice — right for a settings page or anywhere you already made a place for it.

The three floating positions portal out of the component's tree, so no ancestor's
`overflow` or stacking context can clip them. The corners sit where a support
launcher usually sits, so if you also render `<HelpWidget />` on the right, put the
survey on the left.

**One dialog in the center at a time, and there is an order.** Three things open
themselves in the middle of the page — a pending agreement, an unread release note
and this survey — and each learns it wants the screen when its own request comes
back. With no rule the three arrive together, each declaring `aria-modal="true"` to
a screen reader. The center now holds one: the default order is `agreements` →
`whatsNew` → `survey` (the condition the tenant declared, then the thing they
published, then the favour they are asking for), and **waiting costs nothing** — a
survey is spent by being answered, so it appears as soon as the one above it leaves,
or on the next visit.

Whoever is on screen is not replaced by a higher-ranked one arriving later: pulling
the card out from under somebody mid-read is worse than the order being imperfect
for one visit. A dialog somebody ASKED for — the guide's goal picker, say — jumps
the queue, because it answers a click.

The queue is `center` only. A card in a corner or in the flow is nobody's stage to
lose. To change the order:

```ts theme={null}
import { setCenterPriority } from "@userkit/react";

setCenterPriority(["survey", "agreements", "whatsNew"]);
```

**Escape and the click outside are "not now", never an answer.** They hide the card
for this page and spend nothing, so the person is asked again on their next visit —
a reflex is not a decline.

In a Next app, import it from `@userkit/nextjs` — the same component, and in
[proxy mode](/en/customer-auth/session-tokens) the read and the answer are forwarded
by the handlers like the rest of the contact surface, with the session in an httpOnly
cookie on your own origin.

```json theme={null}
{
  "surveys": [
    { "id": "…", "kind": "nps", "question": "How likely are you to recommend us?", "delivered_at": "2026-08-01T08:00:00Z" }
  ]
}
```

`pending` names an **occasion**, not a survey. Nothing here requests one — the API
decides who is asked, from a trigger, an audience and the cooldown — which is why
there is no `requestSurvey` and why an empty list is the ordinary answer.

An unanswered delivery stays pending for **14 days**. A question nobody answered in
two weeks is not a question they still owe an answer to, and a widget that opens a
survey from March is a widget people learn to dismiss without reading. At most five
are ever returned.

Answering **spends the delivery**: a second call answers `not_found`, which is
literally "you were not asked this" — the same thing a withdrawn or long-expired
occasion says. A double-clicked widget writes one response, because the write only
happens if the delivery could be marked answered by an update carrying
`responded_at IS NULL`.

An `nps` survey **demands the score**. A scale answered with a sentence alone is a
verbatim with nothing to divide, and letting it through would put a response in the
numerator of a rate it is not part of. Anything else takes a score, a comment, or
both.

Turning a survey off also stops its outstanding deliveries being pending.

## Results

```http theme={null}
GET /v1/organization/surveys/{id}/results
```

```json theme={null}
{
  "delivered": 412,
  "responses": 96,
  "scored": 94,
  "promoters": 51,
  "passives": 28,
  "detractors": 15,
  "score": 38,
  "trend": [{ "week": "2026-07-27", "scored": 12, "promoters": 7, "detractors": 2, "score": 41 }],
  "verbatims": [{ "score": 9, "comment": "…" }]
}
```

`score` is promoters minus detractors over `scored`, as a percentage, and it is
**`null` when nobody has scored — never `0`**. Zero is a real NPS and a number
somebody would act on; "no answers yet" and "a genuinely neutral result" must not
render as the same thing.

`delivered` is the denominator of your response *rate*; `scored` is the denominator
of the score. They differ whenever somebody answers a `custom` survey with words
alone. The trend is weekly over the last 180 days, UTC weeks — the same midnight
[everything else](/en/guides/analytics) uses.

## The score reaches the rest of the product by join

A contact's read carries `latest_survey_score`, and it is a **join** rather than a
column on the contact. Deliberately: a copy is a second place that can disagree, it
would widen the hottest table on the customer plane, and it would keep exactly the
one answer the join can always produce. It is `null` when nobody has answered.

That is what lets a low score sit next to churn signals on the same screen without
anybody building a pipeline.

`survey.response_recorded` is deliverable as a [webhook](/en/guides/webhooks), which
is the hook for "route a detractor to a human today".

## What is not here yet

**Delivery by email.** Everything above is in-app: a survey is delivered to a
widget the person opened, and there is no mailed version of one.

What exists beside it is [campaigns](/en/guides/campaigns), which can mail a
template carrying the `surveys` category — an audience, a sequence, a cap and an
unsubscribe link. That is a message *about* asking; it does not create a survey
delivery, and an answer to it is not a response in the results above.

## Permissions

`engagement:manage` — owner and admin by default, shared with checklists and the
changelog.
