Feedback form for a website or app

Feedback form for a website or app6 min read

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.

Preet SutharFounder, Ikiform

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.
How each band of NPS scores gets its own follow-up and counts toward the result0 to 6What disappointedyou most?detractorsubtracted7 or 8What would make it a10?passivecounted in the total only9 or 10What do you valuemost?promoteraddedscorefollow-up askscounts asHow each band of NPS scores gets its own follow-up and counts toward the result0 to 6Whatdisappointedyoumost?detractorsubtracted7 or 8Whatwouldmake ita 10?passivecountedin thetotalonly9 or 10What doyouvaluemost?promoteraddedscorefollow-upaskscounts as
  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

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

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.
FieldWhy
scorenumber, 0 to 10, whole numbers only. Stored as a number
reasonThe follow-up. Optional, because a score alone still counts
respondentAn opaque token you generate per person and send. Maps back to the customer in your own database
waveWhich 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
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
<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
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, using score <= 6.
  • The survey form template 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.

Troubleshooting

ProblemWhat to do
422 with Must be at most 10, or a whole-number errorThe 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 waveThe 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 spamThe 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_limitedOne 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.