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

# The survey lifecycle: instrument, token, dispatch, scoring, and benchmark

> How Orbit's survey and Voice-of-Customer subsystem runs end to end: the instrument model and its six dispatch channels, the signed token and short reply tag that gate response submission, the dispatch→open→submit response lifecycle with first-response-wins idempotency, the scoring family (CSAT/NPS/CES, conjoint, MaxDiff), how responses map to CDP traits and events, the VoC analytics rollups, and the frequency caps that keep one survey per respondent window.

# The survey lifecycle

Surveys in Orbit are a full lifecycle, not a form: an **instrument** you
define once, a **dispatch** governed by frequency caps, a **token-gated
response**, a **scored composite**, and a **benchmarked result** that feeds
back into the CDP as contact traits and events. The operational guides are
[Surveys & VoC](/guides/surveys-voc),
[Post-call surveys](/guides/post-call-surveys), and
[Voice survey scorecards](/guides/voice-survey-scorecards); this page is the
concept underneath them. Read it once and "what exactly happens between
sending an NPS survey and segmenting on detractors?" stops being a mystery.

## Section 1 — The instrument model, channels, and tokens

A survey is one tenant-owned row carrying the question copy, an optional
follow-up prompt, up to six dispatch channels (`sms`, `whatsapp`, `email`,
`viber`, `rcs`, `push`), optional translations, and the survey type
(`nps`, `csat`, `ces` — or a multi-question instrument with rating, scale,
choice, matrix, open-text, ranking, conjoint, or MaxDiff questions). Every
send validates the chosen channel against that list before anything else.

Two different token mechanisms gate the two reply paths:

| Token                    | Used by                                                   | Shape                                                                                                                             |
| ------------------------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **Signed landing token** | Email, push, and the "tap" link in SMS/WhatsApp/Viber/RCS | `base64url(payload).base64url(HMAC-SHA256)` — self-contained survey id, contact id, tenant schema, send time, and a 30-day expiry |
| **Reply tag** `[#XXXX]`  | SMS / WhatsApp / Viber / RCS numeric replies              | A 4-character marker appended to the message body so an inbound "7" can be bound to the exact pending response                    |

The signed token carries everything the unauthenticated landing page needs
to atomically write a response — no session, no database lookup on the
public endpoint — and the HMAC is compared in constant time so a recipient
cannot forge a `{survey, contact}` pair by editing the URL. A token that
fails verification returns a generic "link expired" page: the endpoint never
leaks whether a survey id exists.

The `[#XXXX]` reply tag exists because channel replies are bare numbers:
when a customer texts "8" back, the inbound pipeline needs to know *which*
of their pending surveys it answers. One shared parser owns the marker
regex for both the inbound webhook and the surveys controller, so the
format can't drift between the two match paths. Email and push mint no
reply tag — their reply path is the landing-page tap-through.

## Section 2 — The response lifecycle: dispatch → open → submit

Every dispatch — the bulk send endpoint, a flow/automation node, or the
single-contact automation primitive — inserts a `survey_responses` row with
`responded_at = NULL` **before** the message goes out. That write-first
ordering means an inbound numeric reply can match even if the provider send
fails mid-flight, and a failed send keeps the row for retry.

The lifecycle then proceeds:

1. **Dispatch.** The row is written; the message exits via the canonical
   messaging stack (billing, opt-out, and provider routing all apply), or
   via the push device-token fan-out for the push channel.
2. **Open.** A tap on the signed link renders a self-contained page — a
   score row plus optional comment box — or a thank-you page when the
   recipient already answered. Email opens, of course, may arrive days
   later; the 30-day token window covers that without leaving a leaked
   link valid forever.
3. **Submit.** The public submit endpoint upserts the pending row on the
   first tap, and a repeat visit to the same link **updates** `score` and
   `comment` but never resets `responded_at` — `COALESCE(responded_at, NOW())`
   collapses a double-tap or client retry onto one response. The edit
   window, then, is "as long as the token is valid": corrections within the
   30-day window update the same row instead of creating duplicates.

Inbound numeric replies bind through a two-path matcher: first an exact
tag match scoped to contact + channel (a leaked tag can't cross tenants or
be replayed against an unrelated contact), then a legacy fallback to the
most-recent pending row when the tag is missing or mistyped — so a reply
still counts against *some* survey rather than being dropped. Both paths
only ever touch rows with `responded_at IS NULL`.

The same first-response-wins discipline holds on post-call surveys: a
unique partial index on `(survey, call)` collapses a re-triggered post-call
dispatch for the same call onto the first response row, so provider retries
never double-count.

Because the first response is detected and closed before the idempotent
write, downstream side effects — trait writeback, the CDP event, detractor
escalation — fire **exactly once** per response even when a recipient
re-taps the link.

## Section 3 — Scoring, scorecards, and the benchmark

A raw average answers "what is my score?" but not "is it good?". Orbit
composites four read surfaces over the same response rows:

| Surface                                      | What it returns                                                                                                                                                                                                                                                                      |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Per-survey results rollup**                | Answer counts, average score, response rate per survey                                                                                                                                                                                                                               |
| **Scorecards** (`GET /surveys/scorecards`)   | CSAT/NPS bucketed by queue or by agent — answered count, satisfied count (top-2-box, CSAT ≥ 4), average score, and NPS (promoters − detractors)                                                                                                                                      |
| **Choice-based scoring**                     | Conjoint: per-level utilities scaled −100…+100 plus per-attribute importance shares summing to 100. MaxDiff: best/worst rates scaled −100…+100 plus shifted importance shares. Both use counting analysis — deterministic, no external stats dependency, graceful with small samples |
| **Benchmark** (`GET /surveys/:id/benchmark`) | Your measured NPS / CSAT / CES mapped onto published industry bands (10 verticals plus cross-industry): interpolated percentile rank, quartile rating, signed delta vs the median, and a one-line summary                                                                            |

The benchmark refuses to paint a verdict on a handful of responses: below
the minimum sample (30 scored responses by default) it returns
`insufficient_data` with a null percentile while still echoing your measured
value and the bands — the same sample-gating discipline the messaging
health score uses.

The scoring engines themselves are pure functions: the controller does all
database work, the library does the arithmetic, and every choice/benchmark
computation is deterministic — a "trust the model" hand-wave never enters
the pipeline.

## Section 4 — Trait mapping into the CDP

A submitted score is not left sitting in an analytics table. On the first
scored response, two side effects close the loop into the customer data
platform:

1. **Contact-trait writeback.** The response merges
   `nps_score` / `csat_score` / `ces_score`, `is_detractor`,
   `survey_last_score`, and `survey_last_responded_at` into the contact's
   attributes. Segments and computed traits can then filter on
   `nps_score ≤ 6` or `is_detractor = true` directly.
2. **The `survey_responded` CDP track event.** Persisted through the
   canonical event-ingest path — the same chain the SDK `/track` uses — and
   published onto the per-tenant event channel, so realtime computed traits
   recompute and event-triggered journeys fire within about a second. The
   event properties carry `survey_id`, `survey_type`, `channel`, `score`,
   and `is_detractor` — a journey's property filters can target detractors
   specifically ("enrol detractors" needs no extra wiring). A deterministic
   message id keyed on the response row collapses a double-submit onto one
   event row.

Both effects are fail-open: a database or pub/sub blip is logged and
swallowed, because the recipient's response is already persisted and the
HTTP response is already on the wire — a side-effect failure must never
5xx the submission. This is the upstream event source for the
[CDP scoring pipeline](/concepts/cdp-scoring-pipeline) and
[the CDP event model](/concepts/cdp-event-model).

## Section 5 — VoC analytics rollups

Response comments feed the Voice-of-Customer rollup
(`GET /surveys/:id/voc`), a deterministic, dependency-free aggregation over
each response's score, comment, and response time:

* **Sentiment** — a compact, high-precision opinion lexicon classifies each
  verbatim positive / neutral / negative, with negation handling ("not
  good" flips polarity). The thresholds are shared with the conversation
  sentiment surface, so the labels mean the same thing across products.
* **Themes** — recurring tokens that clear a minimum mention floor surface
  as bounded theme lists with per-theme driver analysis (which themes pull
  the score up or down).
* **Verbatims** — the raw comments, capped so the payload stays bounded.
* **Trend** — the score tracked over time, so "is the theme getting
  better?" has a concrete answer.

Everything is scoped and filterable (channel, agent, team, segment) without
a machine-learning dependency — the rollup is auditable by construction,
matching the survey scoring engines' determinism.

## Section 6 — Frequency caps: one survey per respondent window

Two governance layers protect a contact from over-surveying, and every
dispatch path — bulk send, flow node, automation primitive — honours both:

1. **Same-survey dedupe.** A contact sent *this* survey within the fixed
   24-hour window is skipped. This always applies.
2. **Cross-survey fatigue cap.** A contact sent *any* survey within the
   configurable fatigue window (72 hours by default; `0` disables this
   layer) is skipped. This is what keeps concurrent NPS, CSAT, and CES
   programs from collectively over-surveying the same person.

The partition is computed before any message goes out: the audience splits
into eligible, same-survey-skipped, and fatigue-skipped lists, with the
same-survey rule winning the skip-reason accounting so each contact is
counted once. Gets the non-responders back? The reminder view deliberately
requires a fully answered latest wave before it re-targets — a contact
whose latest send is still pending stays visible, and re-sending collapses
through the dedupe window.

## Section 7 — Localization and push delivery

Survey copy localizes at dispatch time, resolved per recipient against the
survey's translation map:

1. exact locale tag match (case-insensitive),
2. primary-subtag match (`pt-PT` request can hit a `pt` variant),
3. the survey's default locale,
4. base copy — always the fallback, so a survey with no translations
   renders exactly as it did before translations existed.

Translations overlay by stable option value, so a partial translation
inherits base copy for the fields it omits. The outbound send path, the
public landing form, and the SDK all resolve locales through the same
resolver, so a recipient sees one consistent language everywhere.

Push delivery skips the single-recipient messaging path entirely: it fans
out to the contact's registered device tokens through the platform's
canonical push infrastructure — opt-out enforcement, dead-token reaping,
and APNs / FCM / Web Push / HMS routing included — and costs nothing, so no
billing leg applies. The tap target is the same signed landing page email
uses.

## Cross-references

* [Surveys & VoC](/guides/surveys-voc) — the operational guide: create,
  dispatch, read results.
* [Post-call surveys](/guides/post-call-surveys) — CSAT/NPS after voice
  interactions, IVR and message-delivery legs.
* [Voice survey scorecards](/guides/voice-survey-scorecards) — queue- and
  agent-level post-conversation scorecards.
* [The CDP scoring pipeline](/concepts/cdp-scoring-pipeline) — the daily
  contact-score rolls survey traits join.
* [The CDP event model](/concepts/cdp-event-model) — the event contracts
  `survey_responded` plugs into.
* [Tenant isolation](/concepts/tenant-isolation) — why every survey and
  response row lives in `tenant_<id>` and the signed token carries its
  schema with it.
