Engagement
NPS survey email: template and code
Ask engaged users for an NPS score with one click, and survey nobody more than once every 90 days.
Passing, tested 2026-10-19 · Email, Inbox
How it works
When to send an NPS survey email
An NPS survey email asks customers how likely they are to recommend you, with one-click score buttons from 0 to 10. It sends 30 days after signup, and if the email isn't opened, an Inbox reminder follows three days later. Nobody gets surveyed more than once every 90 days, and answering cancels the reminder.
- SignupYour app starts the journey
- Day 30NPS survey emailSkipped if the user was surveyed in the last 90 days
- Day 33Inbox reminderOnly if the email wasn't opened
Stops early: When someone answers, cancel the run so the reminder never goes. A second run for the same person inside 90 days ends at the throttle before anything sends.
The waits are Courier delay nodes. We test this exact journey with each wait shortened to seconds.
Messages
What each step sends
Step 1: 30 days after signup
NPS survey email. Skipped if the user was surveyed in the last 90 days.
Hi Dana,
How likely are you to recommend Acme to a friend or colleague? 0 is not at all likely, 10 is extremely likely. Tap the number closest to how you feel.
Other subject lines: "Quick question about Acme", "Dana, how are we doing?"
Step 2: 3 days later
Inbox reminder. Only if the email wasn't opened.
In-app Inbox
How likely are you to recommend Acme to a friend or colleague? It takes one click.
Build
Add it to your app
1. Create the journey
Run setup.ts once with your API key. It creates the templates and the journey, publishes it, and prints the journey ID you pass to step 2. Copy setup.ts, journey.json, and the templates folder from the full definition at the end of this section.
Terminal
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
2. Start it when someone signs up
Node.js
// Start the survey when someone signs up, with the journey ID setup.ts printed.// The journey waits 30 days before it sends. The templates read the first name// from the user's Courier profile. The throttle node keys on data.user_id, so// that's the one value to pass.import Courier from "@trycourier/courier";const client = new Courier();export async function startNpsSurvey(journeyId: string, userId: string) {const { runId } = await client.journeys.invoke(journeyId, {user_id: userId,data: { user_id: userId },});return runId;}
Python
# Start the survey when someone signs up, with the journey ID setup.ts printed.# The journey waits 30 days before it sends. The templates read the first name# from the user's Courier profile. The throttle node keys on data.user_id, so# that's the one value to pass.from courier import Courierclient = Courier()def start_nps_survey(journey_id: str, user_id: str) -> str:response = client.journeys.invoke(template_id=journey_id, user_id=user_id, data={"user_id": user_id})return response.run_id
3. Stop it when they answer
Node.js
// Call this when someone answers the survey. It cancels the run, so the Inbox// reminder never goes. The journey's throttle keeps them from being surveyed// again for 90 days.import Courier from "@trycourier/courier";const client = new Courier();export async function stopNpsSurvey(userId: string) {await client.journeys.cancel({ cancelation_token: `nps-${userId}` });}
Python
# Call this when someone answers the survey. It cancels the run, so the Inbox# reminder never goes. The journey's throttle keeps them from being surveyed# again for 90 days.from courier import Courierclient = Courier()def stop_nps_survey(user_id: str) -> None:client.journeys.cancel(cancelation_token=f"nps-{user_id}")
Full definition: journey.json, setup.ts, and the 2 templates
After a 30-day wait, a throttle node lets each user through once every 90 days, so a second run inside that window ends there. Past it, the survey email goes out, and 3 days later an Inbox reminder follows if the email wasn't opened.
Replace Acme and the survey link in the templates with your own.
journey.json
{"name": "NPS survey email","cancelation_token": "nps-{{recipient}}","nodes": [{"type": "trigger","trigger_type": "api-invoke"},{"type": "delay","mode": "duration","duration": "P30D"},{"type": "throttle","scope": "user","max_allowed": 1,"period": "P90D"},{"id": "nps-email","type": "send","channel": "email","message": {"template": "@nps-email"}},{"type": "delay","mode": "duration","duration": "P3D"},{"type": "send","channel": "inbox","message": {"template": "@nps-reminder-inbox"},"conditions": ["send_status.nps-email", "was not", "OPENED"]},{"type": "exit"}]}
setup.ts
// Creates this recipe's journey from journey.json and the templates/ folder,// then publishes it. Run it once per workspace://// COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts//// It prints the journey ID and the template IDs it created.import Courier from "@trycourier/courier";import { readFileSync, readdirSync } from "node:fs";const client = new Courier();const here = new URL(".", import.meta.url);const readJson = (path: string) =>JSON.parse(readFileSync(new URL(path, here), "utf8"));const spec = readJson("journey.json");// 1. Create the journey, then the templates its send nodes use.const journey = await client.journeys.create({name: spec.name,nodes: [spec.nodes[0], spec.nodes.at(-1)],});const templateIds: Record<string, string> = {};for (const file of readdirSync(new URL("templates/", here))) {const key = file.replace(/\.json$/, "");const template = await client.journeys.templates.create(journey.id,readJson(`templates/${file}`));await client.journeys.templates.publish(template.id, {templateId: journey.id,});templateIds[key] = template.id;}// 2. Save the nodes. In journey.json, "@<template>" stands for the ID of the// template with that file name. Node IDs must be unique across a workspace,// so any node ID journey.json names (used by "send_status.<id>" conditions)// gets this journey's ID added.const nodeIds = [...JSON.stringify(spec.nodes).matchAll(/"id":"([\w-]+)"/g),].map((m) => m[1]);let nodes = JSON.stringify(spec.nodes).replace(/"@([\w-]+)"/g, (_, key) =>JSON.stringify(templateIds[key]));for (const id of nodeIds)nodes = nodes.replace(new RegExp(`(?<=[".])${id}(?=")`, "g"),`${id}-${journey.id}`);await client.journeys.replace(journey.id, {name: spec.name,cancelation_token: spec.cancelation_token,nodes: JSON.parse(nodes),});// 3. Publish, so new runs use this version.await client.journeys.publish(journey.id);console.log(JSON.stringify({ journeyId: journey.id, templateIds }));
templates/nps-email.json
{"channel": "email","notification": {"name": "NPS survey (email)","tags": ["recipe", "nps-survey-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "email","elements": [{"type": "meta","title": "How likely are you to recommend Acme?"},{"type": "text","content": "Hi {{default profile.first_name \"there\"}},"},{"type": "text","content": "How likely are you to recommend Acme to a friend or colleague? 0 is not at all likely, 10 is extremely likely. Tap the number closest to how you feel."},{"type": "action","content": "0","href": "https://app.acme.com/survey?score=0"},{"type": "action","content": "1","href": "https://app.acme.com/survey?score=1"},{"type": "action","content": "2","href": "https://app.acme.com/survey?score=2"},{"type": "action","content": "3","href": "https://app.acme.com/survey?score=3"},{"type": "action","content": "4","href": "https://app.acme.com/survey?score=4"},{"type": "action","content": "5","href": "https://app.acme.com/survey?score=5"},{"type": "action","content": "6","href": "https://app.acme.com/survey?score=6"},{"type": "action","content": "7","href": "https://app.acme.com/survey?score=7"},{"type": "action","content": "8","href": "https://app.acme.com/survey?score=8"},{"type": "action","content": "9","href": "https://app.acme.com/survey?score=9"},{"type": "action","content": "10","href": "https://app.acme.com/survey?score=10"}]}]}}}
templates/nps-reminder-inbox.json
{"channel": "inbox","notification": {"name": "NPS survey reminder (Inbox)","tags": ["recipe", "nps-survey-email"],"brand": null,"subscription": null,"content": {"version": "2022-01-01","elements": [{"type": "channel","channel": "inbox","elements": [{"type": "meta","title": "How are we doing?"},{"type": "text","content": "How likely are you to recommend Acme to a friend or colleague? It takes one click."},{"type": "action","content": "Answer the survey","href": "https://app.acme.com/survey"}]}]}}}
Agents
Hand it to your agent
Give this prompt to Claude Code, Cursor, or Codex with the Courier skill installed. Agents can also read the markdown version and the manifest.
Data
Data fields
| Field | Type | Example | Note |
|---|---|---|---|
| user_id | string | user_123 | The throttle's key, so each user is counted separately |
| profile.first_name | string | Dana | From the profile. Write it when the user signs up. The emails say "there" when it's missing |
FAQ
Frequently asked questions
How do I put one-click score buttons in an NPS email?
Add one button per score from 0 to 10 and link each to your survey URL with the score in the query string, like survey?score=9. Your survey page reads the score from the link and saves it, so the answer takes one click.
What counts as a promoter, a passive, and a detractor?
Scores of 9 and 10 are promoters, 7 and 8 are passives, and 0 through 6 are detractors. Your NPS is the share of promoters minus the share of detractors, so your survey page only needs to save the number from ?score=.
When should I send an NPS survey?
After people have used the product long enough to have an opinion. This recipe waits 30 days after signup, and you can change the first wait to pick another point.
How do I avoid surveying the same person too often?
The journey starts with a throttle node that lets each user through once every 90 days. A second run for the same person inside that window ends at the throttle, so no email and no reminder go out. Change the throttle period to widen or narrow the window. To survey someone again later, start the journey again from your app, for example every six months. Each run waits 30 days before it sends, and the throttle still stops anyone from being surveyed twice inside 90 days.
Recipes
Related recipes
Bring back users who've gone quiet, and restart the clock whenever they log in.
Nudge new users who haven't tried your key feature, and compare two versions of the first email.
Confirm the cancellation, ask why, and make one good offer to come back.
Docs
How each part works
Build this in your workspace
Run setup.ts against a free account and the journey is live in a minute. Prefer no code? Rebuild it in the Journeys editor.