새 SaaS용 대기자 명단

새 SaaS용 대기자 명단5분 분량

지원자를 선별하는 베타 신청 폼 만드는 방법

서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.

Preet SutharIkiform 창업자

이메일 옆에 선별 질문을 몇 개 두고, 폼 백엔드가 이를 강제하게 해서 모든 신청이 빠짐없이, 비교 가능한 형태로 도착하게 하세요. 그런 다음 짧은 점수 스크립트로 목록에서 기수별 참가자를 고르고, 소규모 그룹을 초대하고, 나머지는 다음 차례를 기다리게 하세요.

대기자 명단은 출시일에 모두에게 이메일을 보냅니다. 베타는 정반대입니다. 20명을 들이고, 그들에게서 배우고, 다시 20명을 들입니다. 그래서 폼이 달라집니다. 주소만이 아니라 정렬할 수 있는 답변이 필요합니다.

베타에서 달라지는 점

  • 선발입니다. 한 번에 몇 명씩 초대하므로 지원자끼리 순위를 매길 방법이 필요합니다. 역할이나 팀 규모 같은 선택형 질문은 깔끔하게 정렬됩니다. 자유 입력은 그렇지 않습니다.
  • 불완전한 신청은 자리를 낭비합니다. 적합한지 알려 주는 질문을 누군가 건너뛰면 그 사람을 뽑을 수도, 뺄 수도 없습니다. 선별 질문을 필수로 만들고, 이를 건너뛴 POST는 서버가 거부하게 하세요.
  • 초대는 여러 차례에 걸쳐 나갑니다. 다음 기수가 이미 초대한 사람을 건너뛰도록 목록이 누구를 초대했는지 기억해야 합니다. Ikiform에는 태그가 없으므로 그 상태는 스크립트나 시트에 둡니다.
  • 뽑히지 않은 사람도 중요합니다. 그들이 다음 기수입니다. 감사 문구에서 그룹별로 차례차례 초대한다고 알려 주세요. 그래야 조용한 받은편지함을 거절로 받아들이지 않습니다.

선별 질문

질문은 여섯 개가 한계입니다. 질문마다 누구를 초대할지에 영향을 줘야 합니다. 어떤 답이 그 사람의 순위를 어떻게 올리거나 내릴지 말할 수 없다면 빼세요. 아래 폼을 써 보세요. Ikiform이 서버에서 적용하는 것과 같은 필드 정의를 실행하므로, 불완전한 신청이 받는 422를 볼 수 있습니다.

미리보기

Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.

팀 규모

첫날 맡길 일을 한두 문장으로 적어 주세요.

이 정의를 폼의 Fields 탭에 붙여 넣으세요. use_case 답변은 최소 40자가 필요하므로 한 단어짜리 신청은 걸러집니다.
필드판단하는 것이 타입을 쓰는 이유
email누구를 초대할지필수. 업무용 이메일은 묻지 않아도 회사를 짐작하게 해 줍니다
role해결하려는 문제를 실제로 겪고 있는지직접 정한 구간이 있는 select라서 가중치를 줄 수 있습니다
team_size첫 버전이 그들의 환경에 맞는지radio: 답이 하나라 항상 비교할 수 있습니다
use_case이번 달에 베타를 쓸지최소 길이가 있는 textarea. 폼에서 가장 솔직한 신호입니다
current_tool무엇을 대체하는지, 무엇과 비교할지선택 자유 입력
interview누구와 이야기할 수 있는지checkbox. 없으면 false이므로 신청을 막지 않습니다
필드 정의가 있으면 Ikiform은 모르는 필드를 422와 unknown_field 오류로 거부합니다. ref 같은 숨겨진 입력을 추가한다면 정의에도 추가하세요.

폼

선별 폼은 잘못된 이메일 말고도 실패할 이유가 많으므로, 아래 모든 버전은 서버의 메시지를 해당 필드 아래에 보여 줍니다. 또 POST가 거부되어도 입력한 내용을 유지하므로 아무도 문단을 다시 쓸 필요가 없습니다.

네이티브 required와 minlength가 POST 전에 대부분의 누락을 잡아냅니다. 일반 폼은 스크립트 없이 작동하고 감사 페이지로 이동하며, 스크립트를 쓰면 사람들이 페이지에 머물고 필드 오류가 하나하나 표시됩니다.

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>
</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>
서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

기수 선발하기

신청이 베타 기수가 되는 과정신청빠짐없이, 서버가 강제점수역할, 규모, 성의, 통화상위 25명1기, 초대 완료다음 차례초대한 사람 건너뛰기대기 중그룹별 초대 안내내보내기2주기준선 아래신청이 베타 기수가 되는 과정신청빠짐없이, 서버가 강제점수역할, 규모, 성의, 통화상위 25명1기, 초대 완료다음 차례초대한 사람 건너뛰기대기 중그룹별 초대 안내내보내기2주기준선 아래
  1. 필수 질문을 건너뛴 POST는 Ikiform이 거부하므로 신청은 빠짐없이 도착합니다.
  2. 스크립트가 API로 신청을 읽고 역할, 팀 규모, 사용 사례의 길이, 통화 체크박스로 각각 점수를 매깁니다.
  3. 상위 25명이 첫 기수가 되어 이메일 도구에서 초대를 받습니다.
  4. 2주 뒤 초대한 이메일을 넣고 스크립트를 다시 실행해 그들을 건너뛰고 다음 그룹을 고릅니다.
  5. 기준선 아래의 모든 사람은 목록에 남아 있다가 자기 차례가 오면 연락을 받습니다.
점수는 출발점일 뿐 판정이 아닙니다. 기준선 근처에 있는 사람의 사용 사례는 직접 읽으세요.

스크립트는 REST API로 받은 신청을 모두 읽고, 이메일마다 최신 신청만 남겨 점수를 매기고, 상위 그룹을 cohort.csv에 씁니다. 다음 차례에서 건너뛰도록 이미 초대한 이메일을 인수로 넘기세요. submissions:read 스코프가 있는 시크릿 키가 필요합니다.

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}`);
  • 첫 버전의 대상에 맞게 ROLE과 SIZE를 바꾸세요. 팀용 도구라면 2-10에, 1인용 도구라면 1에 가장 높은 점수를 줄 수 있습니다.
  • 사용 사례는 25단어마다 1점, 최대 3점을 받습니다. 길이가 곧 품질은 아니지만, 세 문장을 쓰는 사람은 대개 실제로 참여합니다.
  • 모든 값을 따옴표로 감싸면 답변 속 쉼표가 CSV를 깨뜨리지 않습니다.
  • 초대한 목록은 $(cat invited.txt)로 넘기는 invited.txt처럼 찾기 쉬운 곳에 보관하세요.

초대 보내기

cohort.csv를 이메일 도구로 가져와 거기서 초대를 보내세요. Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인이 없습니다. 이런 이메일은 웹훅을 트리거로 이메일 도구에서 보내세요. 첫 초대는 짧게 쓰세요. 무엇을 받는지, 꼭 써 봤으면 하는 한 가지, 연락 방법이면 됩니다. Ikiform이 보내는 알림 이메일마다 지원자에게 가는 Reply가 있어서, 기준선 근처의 사람들에게 짧은 개인 메모를 보내기에 좋습니다.

작동 확인하기

  1. 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발 중에는 localhost도 추가하세요. 비워 두면 어디서 온 POST든 받습니다.
  2. 필드 정의를 폼의 Fields 탭에 붙여 넣고 저장하세요.
  3. 사용 사례를 한 줄만 써서 신청해 보세요. 폼이 그 필드 아래에 최소 길이 메시지를 보여 주고, 아무것도 저장되지 않습니다.
  4. 빠짐없이 작성한 신청을 제출하세요. 모든 답변과 함께 Submissions에 나타나고, interview는 true 또는 false입니다.
  5. 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받으며, 폼에 email 필드가 있으면 Reply(답장)는 제출자에게 갑니다.
  6. 허니팟을 채워서 _gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
  7. 시크릿 키로 점수 스크립트를 실행하고 cohort.csv에 테스트 신청이 점수와 함께 있는지 확인하세요.

주의할 점

문제해결 방법
422 validation_failedPOST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요.
ref, utm_source 또는 다른 숨겨진 입력에 대해 Unknown field와 함께 422필드 정의에 없습니다. 선택 text 필드로 추가하거나, Ikiform이 버리도록 이름 앞에 _를 붙이세요.
Next.js에서 오류 후 긴 답변이 사라짐React는 서버 액션 후 폼을 초기화합니다. Next.js 예제처럼 액션에서 답변을 반환하고 각 입력에 defaultValue를 설정하세요.
점수 스크립트가 401 unauthorized 또는 403 forbidden을 던짐401은 키가 없거나 폐기되었다는 뜻이고, 403은 퍼블리셔블 키이거나 스코프가 없다는 뜻입니다. submissions:read 스코프가 있는 시크릿 키(ik_sk_)를 쓰세요.
제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않음스팸으로 저장된 것입니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸 아님으로 표시하면 이메일과 웹훅이 전송됩니다.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.