Skip to main content

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, Post-call surveys, and 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: 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_atCOALESCE(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: 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 and the 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