Waitlist for a new SaaS

Waitlist for a new SaaS7 min read

How to build a beta signup form that screens applicants

A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.

Preet SutharFounder, Ikiform

Ask a few screening questions next to the email, and let the form backend enforce them so every application arrives complete and comparable. Then pick each cohort from the list with a short scoring script, invite a small group, and leave everyone else waiting for the next round.

A waitlist emails everyone on launch day. A beta does the opposite: you let in twenty people, learn from them, then let in twenty more. That changes the form. You need answers you can sort by, not only an address.

What changes for a beta

  • It's a selection. You invite a few people at a time, so you need a way to rank applicants against each other. Closed choices, such as role and team size, sort cleanly. Free text doesn't.
  • Incomplete applications cost a slot. If someone skips the question that tells you whether they fit, you can't pick them and you can't drop them either. Make the screening questions required, and have the server refuse posts that skip them.
  • Invites go out in rounds. Your list needs to remember who you already invited, so the next cohort skips them. Ikiform has no tags, so that state lives in your script or sheet.
  • People who weren't picked still count. They're your next cohort. Tell them on the thank-you line that groups go out over time, so a quiet inbox doesn't read as a no.

Screening questions

Six questions is the ceiling. Each one should change who you invite; if you can't say how an answer would move someone up or down, cut it. Try the form below. It runs the same field definition Ikiform enforces on the server, so you can see the 422 an incomplete application gets.

Preview

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

Team size

A sentence or two about the job you'd hand it on day one.

Paste this definition on the form's Fields tab. The use_case answer needs at least 40 characters, which filters out one-word applications.
FieldWhat it decidesWhy this type
emailWho to inviteRequired. A work email hints at the company without asking
roleWhether they feel the problem you solveA select with your own buckets, so you can weight them
team_sizeWhether your first version fits their setupA radio: one answer, always comparable
use_caseWhether they'll use the beta this monthA textarea with a minimum length. The most honest signal on the form
current_toolWho you're replacing, and what they'll compare you toOptional free text
interviewWho you can talk toA checkbox. Missing means false, so it never blocks an application
With a field definition in place, Ikiform refuses any field it doesn't know with 422 and an unknown_field error. If you add a hidden input such as ref, add it to the definition too.

The form

Every version below shows the server's message under the field it belongs to, because a screening form fails for more reasons than a bad email. Each one keeps what the person typed when a post is refused, so nobody retypes a paragraph.

Native required and minlength catch most gaps before the post. The plain form works with no script and lands on your thank-you page; the script keeps people on the page and lists each field error.

beta.html
<form id="beta-form" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required>

  <label for="role">Your role</label>
  <select id="role" name="role" required>
    <option value="">Choose one</option>
    <option value="founder">Founder</option>
    <option value="engineer">Engineer</option>
    <option value="product">Product manager</option>
    <option value="designer">Designer</option>
    <option value="other">Something else</option>
  </select>

  <fieldset>
    <legend>Team size</legend>
    <label><input type="radio" name="team_size" value="1" required> Only me</label>
    <label><input type="radio" name="team_size" value="2-10"> 2 to 10</label>
    <label><input type="radio" name="team_size" value="11-50"> 11 to 50</label>
    <label><input type="radio" name="team_size" value="51+"> More than 50</label>
  </fieldset>

  <label for="use_case">What would you use it for first?</label>
  <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>

  <label for="current_tool">What do you use today?</label>
  <input id="current_tool" name="current_tool" maxlength="120">

  <label><input type="checkbox" name="interview" value="true"> I'm open to a 20-minute call</label>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden>
  <ul id="beta-errors" role="alert"></ul>
  <button>Apply for the beta</button>
</form>
Before </body>
<script>
  const form = document.getElementById("beta-form");
  const errors = document.getElementById("beta-errors");
  const labels = { email: "Work email", role: "Role", team_size: "Team size", use_case: "First use", current_tool: "Current tool" };

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button");
    button.disabled = true;
    errors.replaceChildren();
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      if (result.ok) {
        form.outerHTML = "<p role=\"status\">Thanks for applying. We invite testers in small groups and will email you either way.</p>";
        return;
      }
      const fields = Object.entries(result.error.fields ?? {});
      const lines = fields.length ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`) : [result.error.message];
      errors.replaceChildren(...lines.map((line) => Object.assign(document.createElement("li"), { textContent: line })));
    } catch {
      errors.textContent = "Couldn't reach the server. Your answers are still here; try again.";
    }
    button.disabled = false;
  });
</script>
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

Pick a cohort

How applications become beta cohortsapplicationscomplete,enforced bythe serverscorerole, size,effort, calltop 25cohort 1,invitednext roundskip anyoneinvitedstillwaitingtold groupsgo out overtimeexporttwo weeksbelow the cutHow applications become beta cohortsapplicationscomplete, enforced bythe serverscorerole, size, effort,calltop 25cohort 1, invitednext roundskip anyone invitedstillwaitingtold groupsgo out overtimeexporttwo weeksbelow the cut
  1. Applications arrive complete, because Ikiform refuses posts that skip a required question.
  2. A script reads them through the API and scores each one on role, team size, the length of the use case and the call checkbox.
  3. The top 25 become the first cohort and get an invite from your email tool.
  4. Two weeks later, run the script again with the invited emails, so they're skipped, and pick the next group.
  5. Everyone below the cut stays on the list and hears from you when their round comes.
Scores are a starting order, not a verdict. Read the use case of everyone near the cut line yourself.

The script reads every received application with the REST API, keeps the newest one per email, scores it and writes the top group to cohort.csv. Pass the emails you've already invited as arguments so the next round skips them. It needs a secret key with the submissions:read scope.

cohort.mjs
import { writeFileSync } from "node:fs";
import { createClient } from "@ikiform/sdk";

const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const COHORT_SIZE = 25;
const invited = new Set(process.argv.slice(2).map((email) => email.toLowerCase()));

const ROLE = { founder: 3, engineer: 3, product: 2, designer: 1, other: 0 };
const SIZE = { "2-10": 3, "11-50": 2, "1": 1, "51+": 1 };

const people = new Map();
for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received", limit: 100 })) {
  const answers = submission.fields;
  const email = String(answers.email ?? "").trim().toLowerCase();
  if (!email || invited.has(email) || people.has(email)) continue;
  const words = String(answers.use_case ?? "").split(/\s+/).length;
  const score =
    (ROLE[answers.role] ?? 0) +
    (SIZE[answers.team_size] ?? 0) +
    Math.min(3, Math.floor(words / 25)) +
    (answers.interview === true ? 2 : 0) +
    (answers.current_tool ? 1 : 0);
  people.set(email, { email, score, role: answers.role, team_size: answers.team_size });
}

const ranked = [...people.values()].sort((a, b) => b.score - a.score);
const cohort = ranked.slice(0, COHORT_SIZE);
const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const rows = cohort.map((person) => [person.email, person.score, person.role, person.team_size].map(quote).join(","));
writeFileSync("cohort.csv", ["email,score,role,team_size", ...rows].join("\n"));
console.log(`${ranked.length} applicants, ${cohort.length} in this cohort, lowest score ${cohort.at(-1)?.score}`);
  • Change ROLE and SIZE to match who your first version is for. A team tool might score 2-10 highest; a solo tool, 1.
  • The use case earns a point per 25 words, up to 3. Length isn't quality, but a person who writes three sentences usually shows up.
  • Quoting every value keeps commas in answers from breaking the CSV.
  • Store the invited list somewhere you'll find it, such as an invited.txt you pass with $(cat invited.txt).

Send the invite

Import cohort.csv into your email tool and send the invite from there. 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. Keep the first invite short: what they get, the one thing you want them to try, and how to reach you. Each notification email Ikiform sends you has a Reply that goes to the applicant, which suits a quick personal note to the people near the cut line.

Check it works

  1. Under the form's Allowed domains, add your site's domain, plus localhost while you develop. Leave it empty to accept posts from anywhere.
  2. Paste the field definition on the form's Fields tab and save it.
  3. Submit an application with a one-line use case. The form shows the minimum-length message under that field, and nothing is stored.
  4. Submit a complete application. It appears under Submissions with every answer, and interview reads true or false.
  5. Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an email field.
  6. Post with the honeypot filled in, _gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
  7. Run the scoring script with a secret key and check cohort.csv lists your test application with its score.

Gotchas

ProblemWhat to do
422 validation_failedThe 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.
422 with Unknown field for ref, utm_source or another hidden inputThe field definition doesn't list it. Add it as an optional text field, or rename it with a leading _ so Ikiform drops it.
A long answer disappears after an error in Next.jsReact resets the form after a server action. Return the answers from the action and set defaultValue on each input, as the Next.js sample does.
The scoring script throws 401 unauthorized or 403 forbiddenA 401 means the key is missing or revoked; a 403 means it is publishable or lacks the scope. Use a secret key (ik_sk_) with the submissions:read scope.
The submission is in the dashboard but no email or webhook arrivedIt 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

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