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 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:
@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 carrieslatest_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 thesurveys 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.