# NPS survey email: template and code

Source: https://www.courier.com/recipes/nps-survey-email (manifest: https://www.courier.com/recipes/nps-survey-email/manifest.json)

Ask engaged users for an NPS score with one click, and survey nobody more than once every 90 days.

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.

Tested 2026-10-19: passing.

The waits are Courier delay nodes. We test this exact journey with each wait shortened to seconds.

## When to send an NPS survey email

| Step | When | Channels | Subject |
| --- | --- | --- | --- |
| 1 | 30 days after signup | Email | How likely are you to recommend Acme? |
| 2 | 3 days later | Inbox |  |

## Copy

### NPS survey (email)

Subject: "How likely are you to recommend Acme?", "Quick question about Acme", "{{profile.first_name}}, how are we doing?"

Hi {{default profile.first_name "there"}},

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.

Button: 0 (https://app.acme.com/survey?score=0)

Button: 1 (https://app.acme.com/survey?score=1)

Button: 2 (https://app.acme.com/survey?score=2)

Button: 3 (https://app.acme.com/survey?score=3)

Button: 4 (https://app.acme.com/survey?score=4)

Button: 5 (https://app.acme.com/survey?score=5)

Button: 6 (https://app.acme.com/survey?score=6)

Button: 7 (https://app.acme.com/survey?score=7)

Button: 8 (https://app.acme.com/survey?score=8)

Button: 9 (https://app.acme.com/survey?score=9)

Button: 10 (https://app.acme.com/survey?score=10)

### NPS survey reminder (Inbox)

Subject: "How are we doing?"

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

Button: Answer the survey (https://app.acme.com/survey)

## The 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"
    }
  ]
}
```

## Create it in your workspace

```bash
COURIER_API_KEY=pk_... node --experimental-strip-types setup.ts
```

setup.ts

```typescript
// 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 }));
```

## Start it when someone signs up

```typescript
// 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
```

## Stop it when they answer

```typescript
// 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}")
```

## Templates

setup.ts reads these from a templates/ folder next to it.

templates/nps-email.json

```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

```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"
            }
          ]
        }
      ]
    }
  }
}
```

## Hand it to your agent

```text
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 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

### 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.

## Related docs

- [Delay node](https://www.courier.com/docs/journeys/nodes/delay)
- [Send node conditions](https://www.courier.com/docs/journeys/nodes/send)
- [Cancel a run](https://www.courier.com/docs/journeys/nodes/cancel)
- [Invoke a journey](https://www.courier.com/docs/journeys/invoke)
- [Send your first in-app message](https://www.courier.com/docs/guides/send-your-first-in-app-message)

## Related recipes

- [Win-back sequence](https://www.courier.com/recipes/win-back-email-sequence): Bring back users who've gone quiet, and restart the clock whenever they log in.
- [Feature adoption emails](https://www.courier.com/recipes/feature-adoption-email-sequence): Nudge new users who haven't tried your key feature, and compare two versions of the first email.
- [Cancellation follow-up](https://www.courier.com/recipes/subscription-cancellation-email): Confirm the cancellation, ask why, and make one good offer to come back.
