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.

  1. Signup
    Your app starts the journey
  2. Day 30
    NPS survey email
    Skipped if the user was surveyed in the last 90 days
  3. Day 33
    Inbox reminder
    Only 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.

Email

From: Acme
How likely are you to recommend Acme?

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.

012345678910

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 are we doing?

How likely are you to recommend Acme to a friend or colleague? It takes one click.

Answer the survey

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 Courier
client = 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 Courier
client = 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.

Add an NPS survey to this app, sent through Courier: an email with one-click 0 to 10 score buttons 30 days after signup, and an Inbox reminder 3 days later if the email wasn't opened, with nobody surveyed more than once every 90 days. Follow the steps at https://www.courier.com/recipes/nps-survey-email.md and use the Courier skill for SDK details. 1. Create the journey and its templates in my Courier workspace by replacing Acme and the survey link in the template files with our own, then running setup.ts from that page with COURIER_API_KEY from our environment. Save the journey ID it prints as COURIER_NPS_JOURNEY_ID. 2. Find where this codebase creates a new user and start the journey there. Pass the fields in the Data fields table (the throttle uses user_id), and make sure first_name is written to their Courier profile at signup. The survey page should read the score from the ?score= query parameter. 3. Find where a survey answer is saved and cancel the journey with the token nps-<user_id>. 4. Match this codebase's language, file layout, and error handling. Never hardcode the API key. 5. When you're done, start one run for a test user and show me the run's steps.

Data

Data fields

FieldTypeExampleNote
user_idstringuser_123The throttle's key, so each user is counted separately
profile.first_namestringDanaFrom 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.

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.