DocsRecipes

Recipes

Survey, NPS, and feedback analytics

4 min readReviewed July 2026

Survey analytics starts before the response. Without eligibility, invitation, delivery, and exposure events, a score cannot reveal who had the opportunity to answer or how representative respondents are.

Keep the survey platform or application database authoritative for response content and contact preferences. Send controlled analytical facts to GraphJSON. Avoid free-text feedback by default; it can contain personal data, health information, credentials, and customer-confidential content.

Model the complete funnel#

eligible
  → invited
  → delivered
  → shown
  → started
  → completed
  → followed up

Each step has a different denominator:

  • invite rate: invited / eligible
  • delivery rate: delivered / attempted invitations
  • impression rate: shown / delivered or eligible in-product opportunities
  • start rate: started / shown
  • completion rate: completed / started or shown
  • response rate: completed / delivered invitations

Choose and label the denominator. “Response rate” is meaningless without it.

Emit eligibility#

{
  "event": "survey_eligibility_evaluated",
  "eligibility_id": "nps_q3:acct_7:2026-07-25",
  "survey_key": "relationship_nps",
  "survey_version": 3,
  "entity_type": "account",
  "account_id": "acct_7",
  "is_eligible": true,
  "eligibility_reason": "active_customer_90d",
  "evaluation_at": "2026-07-25T16:00:00Z",
  "policy_version": 2
}

Eligibility should include frequency caps, consent, account lifecycle, recent support cases when appropriate, and suppression rules. Do not reconstruct it from current state after the campaign.

Keep delivery and exposure separate#

Email delivery does not prove a person saw the invitation. An in-product trigger does not prove the component rendered.

{
  "event": "survey_impression",
  "survey_key": "relationship_nps",
  "survey_version": 3,
  "invitation_id": "inv_91",
  "respondent_id": "rsp_42",
  "account_id": "acct_7",
  "surface": "product_home",
  "trigger": "session_start",
  "eligibility_policy_version": 2
}

Use pseudonymous respondent IDs. Keep email addresses and delivery tokens out of GraphJSON.

Model responses without overcollection#

For a scored response:

{
  "event": "survey_response_completed",
  "response_id": "resp_81",
  "invitation_id": "inv_91",
  "survey_key": "relationship_nps",
  "survey_version": 3,
  "respondent_id": "rsp_42",
  "account_id": "acct_7",
  "score": 8,
  "score_scale": "0_10",
  "response_context": "in_product",
  "account_tenure_bucket": "90_179d",
  "plan_at_response": "business"
}

For categorical feedback, send reviewed categories such as onboarding, reliability, or missing_capability. Do not send the comment body unless there is a specific, privacy-reviewed need and access model.

If users may edit a response, preserve response version or select the latest provider record deliberately. A retry must not count as a second respondent.

Calculate NPS exactly#

For the standard 0–10 question:

  • promoters: 9–10
  • passives: 7–8
  • detractors: 0–6
NPS
  = 100 × (promoters / valid responses − detractors / valid responses)

Query one latest valid response per response ID:

WITH latest AS (
  SELECT
    JSONExtractString(json, 'response_id') AS response_id,
    argMax(JSONExtractInt(json, 'score'), timestamp) AS score,
    argMax(JSONExtractInt(json, 'survey_version'), timestamp) AS survey_version
  FROM survey_events
  WHERE
    JSONExtractString(json, 'event') = 'survey_response_completed'
    AND JSONExtractString(json, 'survey_key') = 'relationship_nps'
  GROUP BY response_id
)
SELECT
  survey_version,
  count() AS responses,
  countIf(score >= 9) AS promoters,
  countIf(score <= 6) AS detractors,
  100.0 * (promoters - detractors) / nullIf(responses, 0) AS nps
FROM latest
WHERE score BETWEEN 0 AND 10
GROUP BY survey_version

Do not average the 0–10 scores and label the result NPS. Show response count and survey version beside the score.

Measure response coverage#

A score can rise because unhappy customers stopped answering. Compare respondents with the eligible population across:

  • plan
  • tenure
  • region and language
  • account size
  • recent product activity
  • recent incident or support experience
  • invitation surface

Report invite, impression, and response rates by these dimensions. Weighting can adjust known population differences but cannot repair unknown or unmeasured nonresponse. Document the weighting model and show weighted and unweighted results.

Respect maturity and cadence#

Do not compare an open campaign with a closed one. Define:

  • invitation window
  • response window
  • late-response policy
  • repeat-eligibility interval
  • reporting cutoff

Relationship surveys, post-transaction surveys, and support CSAT measure different experiences. Do not combine them into one trend because each has a score.

Join behavior carefully#

Useful questions include:

  • Did respondents activate successfully before answering?
  • Does sentiment differ by adoption breadth?
  • Do detractors experience more reliability failures?
  • Does follow-up reduce later support friction?

Use behavior that occurred before the response when interpreting sentiment. Joining future behavior can answer a predictive question, but not explain what the respondent knew at response time.

High adoption and high NPS may share a cause such as account maturity. Survey analysis is observational unless assignment or intervention is controlled. Do not claim that a feature caused sentiment from a correlation.

Track follow-up as a workflow#

{
  "event": "survey_followup_completed",
  "response_id": "resp_81",
  "survey_key": "relationship_nps",
  "followup_type": "customer_success_outreach",
  "reason_category": "reliability",
  "outcome": "conversation_completed",
  "days_since_response": 2
}

Keep private notes and message content in the CRM or support tool. GraphJSON should receive a bounded outcome category, not the conversation transcript.

Avoid rewarding teams for closing follow-up tasks without measuring whether the customer issue was understood or resolved.

Protect respondent privacy#

Apply:

  • data minimization
  • pseudonymous IDs
  • controlled categories
  • access restrictions outside GraphJSON for verbatim text
  • suppression of small segments
  • consent and contact-preference enforcement in the authority
  • retention and deletion procedures

Sentiment combined with account and product behavior can be sensitive even without a name. Avoid dashboards that identify an individual respondent unless the operational workflow explicitly requires and authorizes it.

Build the dashboard#

Include:

  1. eligible, invited, delivered, shown, and completed counts
  2. response rate with a named denominator
  3. NPS or other score with response count and uncertainty
  4. promoter, passive, and detractor distribution
  5. coverage by plan, tenure, account size, and surface
  6. controlled reason categories
  7. follow-up status and outcome
  8. survey version, campaign maturity, and freshness

Do not rank small segments without minimum-count and privacy thresholds.

Launch checklist#

  • Eligibility, delivery, exposure, and response are separate facts.
  • Every rate names its denominator.
  • Survey and eligibility versions travel with events.
  • Responses deduplicate by stable response ID.
  • NPS uses promoter minus detractor percentages.
  • Open and closed response windows are not compared as peers.
  • Respondent coverage and nonresponse bias are visible.
  • Verbatim text is excluded from GraphJSON by default.
  • Behavior joins respect event time and causal limits.
  • Follow-up records outcomes without copying private notes.
Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support