Skip to main content
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. 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

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

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