# Run an NPS survey on your own site, from email link to score

> Run an NPS survey with a 0 to 10 score in the email, a follow-up question on your own page that changes with the score, and a script that calculates NPS.

Published 2026-10-25 · https://ikiform.com/guides/feedback-form/nps-survey

Put eleven links, 0 to 10, in the survey email, each pointing to a page on your site with the score and an opaque respondent token in the URL. The page preselects the score, asks a follow-up that depends on it, and records nothing until the person presses Send. Store the score in a number field from 0 to 10, then calculate NPS as the share of 9s and 10s minus the share of 0 to 6.

## What's different about NPS

NPS asks one question with a fixed scale: how likely are you to recommend us, from 0 to 10. Three details make it unlike the 1 to 5 rating in the pillar:

- **The scale starts at 0.** Ikiform's `rating` field runs from 1 to at most 10, so it can't hold a 0. NPS uses a `number` field with `min: 0`, `max: 10` and `integer: true`, stored as a number.
- **It's usually sent by email.** Email clients don't reliably submit forms or run scripts, so the email carries links, and the form lives on your site. Ikiform has no hosted form pages.
- **Links in email get clicked by machines.** Many mail security scanners open every link in a message to check it. If opening a link recorded a score, you'd collect scores from scanners. The page only records when a person presses Send.

Diagram: How each band of NPS scores gets its own follow-up and counts toward the result

1. A score from 0 to 6 asks what disappointed them, and counts as a detractor.
2. A 7 or 8 asks what would make it a 10, and counts only toward the total.
3. A 9 or 10 asks what they value most, and counts as a promoter.
4. NPS is the percentage of promoters minus the percentage of detractors, from minus 100 to 100.

The question changes with the score because the useful answer does. A detractor's reason is a problem to fix; a promoter's is copy for your homepage.

## The fields

Form: NPS survey

- **How likely are you to recommend us to a friend or colleague?** (`score`, number, required)
- **What's the main reason for your score?** (`reason`, textarea)
- **Respondent** (`respondent`, text)
- **Survey wave** (`wave`, text, required)

Try a score of 11 or 7.5: Ikiform refuses both. A missing `wave` is refused too, so every answer belongs to a survey round.

| Field | Why |
| --- | --- |
| `score` | `number`, 0 to 10, whole numbers only. Stored as a number |
| `reason` | The follow-up. Optional, because a score alone still counts |
| `respondent` | An opaque token you generate per person and send. Maps back to the customer in your own database |
| `wave` | Which round, such as `2026-q4`. Lets you compare quarters without date filters |

Don't put the customer's email or account id in the link. URLs end up in browser history, proxy logs and forwarded emails. A random token of 16 characters or more tells you who answered and nothing to anyone else.

## The survey email

Build the row of links with your email tool's merge tags, or generate it in the code that sends the email. This function returns the HTML for one respondent:

`nps-email.js`:

```js
function npsRow(respondent, wave) {
  const cells = Array.from({ length: 11 }, (_, score) => {
    const link = new URL("https://example.com/nps");
    link.searchParams.set("score", String(score));
    link.searchParams.set("r", respondent);
    link.searchParams.set("wave", wave);
    return `<td><a href="${link}" style="display:block;padding:8px 10px">${score}</a></td>`;
  });
  return `<table role="presentation"><tr>${cells.join("")}</tr></table>
<p>0 is not at all likely, 10 is extremely likely.</p>`;
}
```

Keep the email to the question and the row of numbers. A long intro pushes the scale below the fold on phones, and most answers come from people who click a number without scrolling.

## The landing page

Put this at `/nps` on your site. It draws the 0 to 10 scale, preselects the score from the link, sets the follow-up question for that score, and posts with `fetch` only when the person presses Send. If they change the score on the page, the question changes with it.

`nps.html`:

```html
<form id="nps" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>How likely are you to recommend us to a friend or colleague?</legend>
    <div id="nps-scale"></div>
  </fieldset>
  <label for="reason" id="reason-label">What's the main reason for your score?</label>
  <textarea id="reason" name="reason" rows="4" maxlength="4000"></textarea>
  <input type="hidden" name="respondent" />
  <input type="hidden" name="wave" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="nps-status" role="status"></p>
</form>

<script>
  const QUESTIONS = [
    [6, "What disappointed you most?"],
    [8, "What would make it a 10 for you?"],
    [10, "What do you value most about it?"],
  ];
  const params = new URLSearchParams(location.search);
  const form = document.getElementById("nps");
  const label = document.getElementById("reason-label");
  const scale = document.getElementById("nps-scale");

  for (let score = 0; score <= 10; score += 1) {
    scale.insertAdjacentHTML(
      "beforeend",
      `<label><input type="radio" name="score" value="${score}" required /> ${score}</label>`,
    );
  }

  function askFor(score) {
    label.textContent = QUESTIONS.find(([top]) => score <= top)[1];
  }

  const preset = Number(params.get("score"));
  if (params.has("score") && Number.isInteger(preset) && preset >= 0 && preset <= 10) {
    form.elements.score.value = String(preset);
    askFor(preset);
  }
  form.elements.respondent.value = params.get("r") ?? "";
  form.elements.wave.value = params.get("wave") ?? "";
  scale.addEventListener("change", (event) => askFor(Number(event.target.value)));

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const status = document.getElementById("nps-status");
    status.textContent = "Sending";
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    }).catch(() => null);
    const result = await response?.json().catch(() => null);
    if (result?.ok) {
      form.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Thanks for answering." }));
      return;
    }
    status.textContent = result?.error?.fields?.score?.[0] ?? result?.error?.message ?? "Couldn't send it. Try again.";
  });
</script>
```

> The page also works without a link: someone who opens `/nps` directly sees the scale with nothing selected. Their answer is refused for a missing `wave` unless you set a default, which keeps stray visits out of your numbers.

## Calculate the score

Read every answer for a wave with the SDK, keep the latest answer per respondent, and apply the formula. Run it with a secret key in `IKIFORM_SECRET_KEY`:

`nps.mjs`:

```js
import { createClient } from "@ikiform/sdk";

const WAVE = process.argv[2] ?? "2026-q4";
const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const latest = new Map();

for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received" })) {
  const { score, respondent, wave } = submission.fields;
  if (wave !== WAVE) continue;
  const key = respondent || submission.id;
  const seen = latest.get(key);
  if (!seen || seen.at < submission.created_at) latest.set(key, { score, at: submission.created_at });
}

const scores = [...latest.values()].map((answer) => answer.score);
const promoters = scores.filter((value) => value >= 9).length;
const detractors = scores.filter((value) => value <= 6).length;
const nps = Math.round(((promoters - detractors) / scores.length) * 100);

console.log(`${WAVE}: ${scores.length} people, ${promoters} promoters, ${detractors} detractors, NPS ${nps}`);
```

The script keeps one answer per respondent because people sometimes click a second number after changing their mind. Ikiform doesn't merge or reject repeat submissions by email. An identical repeat within 24 hours only adds to the spam score. Deduplicate where the list lives, or when you export.

## Check it works

1. Open `/nps?score=3&r=test_respondent_0001&wave=2026-q4`. The 3 is selected and the question reads "What disappointed you most?"
2. Click 9. The question changes to "What do you value most about it?"
3. Open the same link with `curl`. Nothing new appears under **Submissions**, because nothing was sent.
4. Press Send. The answer shows `score` as the number 9.
5. Run `node nps.mjs 2026-q4`. It reports one person and one promoter.

## Gotchas

- Don't survey the same customer too often. Pick an interval, such as once a quarter, and keep the respondent list on your side.
- Small samples swing wildly. With 20 answers, one person moves NPS by 5 points. Report the number of people next to the score.
- Low scores deserve a reply. Send detractors to your team chat with a webhook relay like the pillar's [low-score alert](https://ikiform.com/guides/feedback-form.md#routing), using `score <= 6`.
- The survey [form template](https://ikiform.com/forms/survey) asks the score inside a longer survey. That suits a one-off study; a standalone NPS question gets more answers.
- Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a [webhook](https://ikiform.com/docs/webhooks.md).

## Troubleshooting

| Problem | What to do |
| --- | --- |
| `422` with `Must be at most 10`, or a whole-number error | The score came from somewhere other than the scale, such as a hand-edited URL. The page only accepts 0 to 10 from the link; check your email template's links. |
| `422` naming `wave` | The link didn't carry `wave`, or it doesn't match `2026-q4`-style values. Fix the email template, or set a default in the page script. |
| `403 origin_not_allowed`, or every post lands in spam | The page's domain isn't in the form's **Allowed domains**. Add it, plus `localhost` for local tests. Subdomains of a listed domain are allowed. |
| `429 rate_limited` | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for `Retry-After`, or choose Relaxed or Custom for forms that post from one shared IP. |

## Questions

### How do I put an NPS question in an email?

Put eleven links, 0 to 10, in the email, each opening a page on your site with the score in the URL. The page preselects it, asks a follow-up, and records the answer when the person presses Send.

### Can I use Ikiform's rating field for NPS?

No. A rating runs from 1 to at most 10, and NPS needs 0. Use a number field with min 0, max 10 and integer true.

### How is NPS calculated?

Take the percentage of answers that are 9 or 10, subtract the percentage that are 0 to 6, and ignore 7 and 8 except in the total. The result runs from minus 100 to 100.

### Why not record the score as soon as the link is clicked?

Mail security scanners open links in incoming email to check them. Recording on click would count those visits as answers. Recording only on Send keeps the numbers honest.

## More guides

- [Feedback form for a website or app](https://ikiform.com/guides/feedback-form.md): Put a feedback form on any page or inside your app: a rating, a comment, the page it came from, spam filtering, and alerts when a score is low.
- [Cancellation survey](https://ikiform.com/guides/feedback-form/cancellation-survey.md): Ask why customers cancel inside your SaaS cancel flow, keep Skip one click away, save the reason to Stripe and Ikiform, and never delay the cancel.
- [Beta tester feedback](https://ikiform.com/guides/feedback-form/beta-tester-feedback.md): A beta feedback form with a personal link per tester and build, two rating scales and a weekly summary that shows whether each build got better.
- [React feedback widget](https://ikiform.com/guides/feedback-form/react-feedback-widget.md): A floating feedback button for a React app that opens a small panel, sends the screen, app version and account with each answer, and never loses a draft.
- [Bug report form](https://ikiform.com/guides/feedback-form/bug-report.md): A bug report form that asks people for steps and severity, and lets the page add the URL, browser, viewport and recent errors on its own.
- [Feature request form](https://ikiform.com/guides/feedback-form/feature-request.md): A feature request form that asks for the problem instead of the solution, stores requests by theme, counts people not rows, and emails them when it ships.
