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.
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
ratingfield runs from 1 to at most 10, so it can't hold a 0. NPS uses anumberfield withmin: 0,max: 10andinteger: 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.
- A score from 0 to 6 asks what disappointed them, and counts as a detractor.
- A 7 or 8 asks what would make it a 10, and counts only toward the total.
- A 9 or 10 asks what they value most, and counts as a promoter.
- NPS is the percentage of promoters minus the percentage of detractors, from minus 100 to 100.
The fields
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
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:
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.
<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>/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:
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
- Open
/nps?score=3&r=test_respondent_0001&wave=2026-q4. The 3 is selected and the question reads "What disappointed you most?" - Click 9. The question changes to "What do you value most about it?"
- Open the same link with
curl. Nothing new appears under Submissions, because nothing was sent. - Press Send. The answer shows
scoreas the number 9. - 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
| 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.