Feedback form for a website or app6 min read
Collect feature requests you can count, rank and answer
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.
Ask what the person is trying to do and how they do it today, not which feature they want, and require an email so you can tell them when it ships. Store each request in a table by webhook, tag requests into themes, and rank themes by how many different people are blocked. When a theme ships, email everyone who asked.
What's different about feature requests
A bug report is done when it's fixed. A feature request is a data point: one of many people describing the same gap in different words. Three things follow from that:
- People describe solutions. "Add a CSV button" might mean "my accountant needs last month's invoices". Ask for the job, and you can pick a better fix than the one requested.
- The same need arrives many times. Ikiform stores every post and doesn't merge repeats, and two people rarely use the same words anyway. You group requests yourself, by theme, and count distinct people.
- The value is in the follow-up. Telling someone "you asked, we built it" is the best reason to ask at all. That needs an email on every request, so this form requires one.
- Someone describes what they're trying to do, picks an area and an impact, and leaves an email.
- A webhook relay inserts the request into your database, once per submission id.
- Each week you tag new requests with a theme, by hand or with a pattern.
- If it fits an existing theme, that theme now has one more person behind it.
- If not, it starts a new theme.
- When a theme ships, you select every email in it and send one message.
Ask for the problem
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
problem has a 30-character minimum, which turns "dark mode pls" into a sentence with a reason in it.| Field | Why it's there |
|---|---|
problem | The job to be done, in their words. The most useful field by far |
workaround | Tells you how painful it is. "I copy it into a spreadsheet by hand" is a strong signal |
area | A coarse first sort, so you tag within billing, not across everything |
impact | Separates blocked customers from curious ones, phrased as outcomes people can judge |
email | Required. Without it you can't close the loop or ask a question |
Leave out a priority or vote field. Everyone marks their own request as high priority, and the count of people per theme is a better vote than any button.
The form
The form needs no script: it posts and redirects. Set Redirect after submit to a page that says what happens next. For a version that stays on the page in your framework, take the pillar's form code and swap in these fields.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="problem">What are you trying to do?</label>
<textarea id="problem" name="problem" rows="5" required minlength="30"
placeholder="Send last month's invoices to our accountant in one go"></textarea>
<label for="workaround">How do you do it today?</label>
<textarea id="workaround" name="workaround" rows="3"></textarea>
<label for="area">Which part of the product?</label>
<select id="area" name="area" required>
<option value="billing">Billing and invoices</option>
<option value="reports">Reports</option>
<option value="team">Team and permissions</option>
<option value="integrations">Integrations and API</option>
<option value="other">Something else</option>
</select>
<fieldset>
<legend>How much does it matter?</legend>
<label><input type="radio" name="impact" value="blocking" required /> It stops us using the product for something</label>
<label><input type="radio" name="impact" value="slow" /> It costs us time every week</label>
<label><input type="radio" name="impact" value="nice" /> It would be nice</label>
</fieldset>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send request</button>
</form>Store requests by webhook
The dashboard and CSV export work for the first few dozen requests. Once you're tagging themes and counting people every week, a table is easier. Create one in Postgres:
create table feature_requests (
submission_id text primary key,
area text not null,
impact text not null,
problem text not null,
workaround text,
email text not null,
theme text,
created_at timestamptz not null
);Then add a webhook endpoint in Ikiform that points at this route. It runs on any Node host with the pg package, and your own server covers the same pattern in more depth:
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const INSERT = `insert into feature_requests (submission_id, area, impact, problem, workaround, email, created_at)
values ($1, $2, $3, $4, $5, lower($6), $7)
on conflict (submission_id) do nothing`;
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { problem, workaround, area, impact, email } = event.data.fields;
await pool.query(INSERT, [
event.data.submission_id,
area,
impact,
problem,
workaround ?? null,
email,
event.data.created_at,
]);
return new Response(null, { status: 204 });
}verifyWebhookrejects anything not signed with your endpoint'swhsec_secret.on conflict (submission_id) do nothingmakes retries safe. Ikiform resends a failed delivery with the same submission, and the second insert is a no-op.- A database error throws, the route answers
500, and Ikiform retries later. You don't lose requests during a database restart. - The email is lowercased on the way in, so counting distinct people works.
Tag and rank
Tag new requests
Once a week, read the untagged rows and give each a theme. Patterns handle the obvious ones; read the rest yourself, because the interesting requests don't match a pattern.
update feature_requests
set theme = 'invoice-export'
where theme is null
and area = 'billing'
and problem ilike any (array['%export%', '%accountant%', '%download%']);Rank themes
This ranks themes by how many requests say they're blocked, then by how many different people asked:
select coalesce(theme, 'untagged') as theme,
count(*) as requests,
count(distinct email) as people,
count(*) filter (where impact = 'blocking') as blocking
from feature_requests
group by 1
order by blocking desc, people desc;- Read the top three themes'
problemtexts in full before you design anything. - Look at the workarounds. Often one small change removes the worst of it.
- Weigh the ranking against your plan; it shows demand, not what's right to build.
Close the loop
When a theme ships, pull everyone who asked for it:
select distinct email
from feature_requests
where theme = 'invoice-export';Send one short email from your email tool or your own inbox, saying what shipped and linking to it. 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. The same applies here: the launch email comes from you, not from Ikiform.
Check it works
- Submit a request with a 10-character problem. Ikiform answers
422namingproblem. - Submit a real request. It appears under Submissions and as a row in
feature_requests. - Under Webhooks, resend the delivery. The table still has one row for that submission.
- Submit twice with the same email in different case. The ranking query counts one person.
Gotchas
- A public feedback board, such as Canny, adds voting and a public status page. If you want customers to see each other's requests, that's the better tool; this setup keeps requests private.
- Requests that include several links can score as spam. Check the Spam filter weekly while volume is low.
- If you add an
areaoption later, add it to the definition first. A value that isn't listed is refused. - Store what you need and no more. A requests table with emails is personal data; say so in your privacy notice and delete rows you no longer need.
Troubleshooting
| Problem | What to do |
|---|---|
| Rows missing from the table | Open the endpoint under Webhooks. A 400 means the secret is wrong; a 500 with a database error means the insert failed and Ikiform is retrying. |
422 validation_failed | The post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks. |
Questions
What the person is trying to do, how they do it today, which part of the product it touches, how much it matters, and an email. Asking for the problem rather than the feature gives you room to find a better fix.
Expect them and count people, not rows. Tag each request with a theme and rank themes by distinct emails. Ikiform stores every submission and doesn't merge repeats.
Not with a form alone. A form keeps requests private; a public board such as Canny adds voting. Counting distinct people per theme gives you a private equivalent.
Keep the email with each request, select every email in the shipped theme, and send one message from your email tool. Ikiform doesn't email submitters itself.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.