Liste d'attente pour un nouveau SaaS

Liste d'attente pour un nouveau SaaS8 min de lecture

Comment créer un formulaire d'inscription à une bêta qui sélectionne les candidats

Un formulaire de candidature bêta avec des questions de sélection appliquées côté serveur, et un script qui note les candidats et choisit chaque cohorte.

Preet SutharFondateur, Ikiform

Posez quelques questions de sélection à côté de l'e-mail, et laissez le backend de formulaires les faire respecter pour que chaque candidature arrive complète et comparable. Choisissez ensuite chaque cohorte dans la liste avec un court script de notation, invitez un petit groupe, et laissez les autres attendre le prochain tour.

Une liste d'attente écrit à tout le monde le jour du lancement. Une bêta fait l'inverse : vous faites entrer vingt personnes, apprenez d'elles, puis en faites entrer vingt autres. Cela change le formulaire. Il vous faut des réponses que vous pouvez trier, pas seulement une adresse.

Ce qui change pour une bêta

  • C'est une sélection. Vous invitez quelques personnes à la fois : il vous faut donc un moyen de classer les candidats entre eux. Les choix fermés, comme le rôle et la taille de l'équipe, se trient proprement. Le texte libre, non.
  • Une candidature incomplète coûte une place. Si quelqu'un saute la question qui vous dit s'il correspond, vous ne pouvez ni le choisir ni l'écarter. Rendez les questions de sélection obligatoires, et faites refuser par le serveur les envois qui les sautent.
  • Les invitations partent par vagues. Votre liste doit se souvenir de qui vous avez déjà invité, pour que la cohorte suivante les ignore. Ikiform n'a pas de tags : cet état vit donc dans votre script ou votre tableur.
  • Les personnes non retenues comptent toujours. C'est votre prochaine cohorte. Dites-leur dans la phrase de remerciement que les groupes sont ouverts au fil du temps, pour qu'une boîte silencieuse ne soit pas prise pour un refus.

Questions de sélection

Six questions, c'est le maximum. Chacune doit changer qui vous invitez ; si vous ne savez pas dire comment une réponse ferait monter ou descendre quelqu'un, supprimez-la. Essayez le formulaire ci-dessous. Il exécute la même définition de champs qu'Ikiform applique côté serveur : vous voyez donc le 422 que reçoit une candidature incomplète.

Aperçu

Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.

Taille de l'équipe

Une ou deux phrases sur la tâche que vous lui confieriez dès le premier jour.

Collez cette définition dans l'onglet Fields du formulaire. La réponse use_case exige au moins 40 caractères, ce qui écarte les candidatures en un mot.
ChampCe qu'il permet de déciderPourquoi ce type
emailQui inviterObligatoire. Un e-mail professionnel donne une idée de l'entreprise sans la demander
roleS'ils ressentent le problème que vous résolvezUn select avec vos propres catégories, pour pouvoir les pondérer
team_sizeSi votre première version convient à leur configurationUn radio : une seule réponse, toujours comparable
use_caseS'ils utiliseront la bêta ce mois-ciUn textarea avec une longueur minimale. Le signal le plus sincère du formulaire
current_toolQui vous remplacez, et à quoi ils vous comparerontTexte libre facultatif
interviewAvec qui vous pouvez échangerUne checkbox. Absente signifie false, elle ne bloque donc jamais une candidature
Avec une définition de champs en place, Ikiform refuse tout champ qu'il ne connaît pas avec 422 et une erreur unknown_field. Si vous ajoutez un input masqué comme ref, ajoutez-le aussi à la définition.

Le formulaire

Chaque version ci-dessous affiche le message du serveur sous le champ concerné, car un formulaire de sélection échoue pour plus de raisons qu'un e-mail invalide. Chacune conserve ce que la personne a saisi quand un envoi est refusé, pour que personne ne retape un paragraphe.

Les attributs natifs required et minlength détectent la plupart des oublis avant l'envoi. Le formulaire classique fonctionne sans script et arrive sur votre page de remerciement ; le script garde les gens sur la page et liste chaque erreur de champ.

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>
Avant </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>
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Choisir une cohorte

Comment les candidatures deviennent des cohortes de bêtacandidaturescomplètes,imposé parle serveurnoterôle, taille,effort, appeltop 25cohorte 1,invitéetoursuivantignorer lesinvitéstoujoursen attenteprévenus desvagues àvenirexportdeux semainessous la barreComment les candidatures deviennent des cohortes de bêtacandidaturescomplètes, imposé parle serveurnoterôle, taille, effort,appeltop 25cohorte 1, invitéetour suivantignorer les invitéstoujoursen attenteprévenus desvagues àvenirexportdeux semainessous la barre
  1. Les candidatures arrivent complètes, car Ikiform refuse les envois qui sautent une question obligatoire.
  2. Un script les lit via l'API et note chacune selon le rôle, la taille de l'équipe, la longueur du cas d'usage et la case d'appel.
  3. Les 25 premiers forment la première cohorte et reçoivent une invitation depuis votre outil d'e-mailing.
  4. Deux semaines plus tard, relancez le script avec les e-mails déjà invités, pour qu'ils soient ignorés, et choisissez le groupe suivant.
  5. Tous ceux sous la barre restent sur la liste et auront de vos nouvelles quand viendra leur tour.
Les notes sont un ordre de départ, pas un verdict. Lisez vous-même le cas d'usage de tous ceux qui sont proches de la barre.

Le script lit chaque candidature reçue avec l'API REST, garde la plus récente par e-mail, la note et écrit le groupe de tête dans cohort.csv. Passez en arguments les e-mails déjà invités pour que le tour suivant les ignore. Il lui faut une clé secrète avec le scope 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}`);
  • Adaptez ROLE et SIZE au public de votre première version. Un outil d'équipe pourrait mieux noter 2-10 ; un outil individuel, 1.
  • Le cas d'usage rapporte un point par tranche de 25 mots, jusqu'à 3. La longueur n'est pas la qualité, mais quelqu'un qui écrit trois phrases est généralement au rendez-vous.
  • Mettre chaque valeur entre guillemets évite que des virgules dans les réponses ne cassent le CSV.
  • Rangez la liste des invités dans un endroit où vous la retrouverez, comme un invited.txt que vous passez avec $(cat invited.txt).

Envoyer l'invitation

Importez cohort.csv dans votre outil d'e-mailing et envoyez l'invitation depuis celui-ci. Ikiform n'envoie pas d'e-mail à la personne qui remplit le formulaire. Les e-mails de notification vont uniquement à vous et à votre équipe : il n'y a donc ni réponse automatique, ni e-mail de confirmation, ni double opt-in. Envoyez-les depuis votre outil d'e-mailing, déclenché par un webhook. Gardez la première invitation courte : ce qu'ils obtiennent, la chose que vous voulez qu'ils essaient, et comment vous joindre. Chaque e-mail de notification qu'Ikiform vous envoie a un bouton Reply qui écrit au candidat, ce qui convient à un petit mot personnel aux personnes proches de la barre.

Vérifiez que tout fonctionne

  1. Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus localhost pendant le développement. Laissez vide pour accepter les envois de partout.
  2. Collez la définition des champs dans l'onglet Fields du formulaire et enregistrez-la.
  3. Envoyez une candidature avec un cas d'usage d'une ligne. Le formulaire affiche le message de longueur minimale sous ce champ, et rien n'est stocké.
  4. Envoyez une candidature complète. Elle apparaît sous Submissions avec toutes les réponses, et interview vaut true ou false.
  5. Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et Reply répond à l'expéditeur quand le formulaire a un champ email.
  6. Envoyez avec le honeypot rempli, _gotcha=bot. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook.
  7. Lancez le script de notation avec une clé secrète et vérifiez que cohort.csv liste votre candidature de test avec sa note.

Pièges

ProblèmeQue faire
422 validation_failedL'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ.
422 avec Unknown field pour ref, utm_source ou un autre input masquéLa définition des champs ne le liste pas. Ajoutez-le comme champ text facultatif, ou renommez-le avec un _ initial pour qu'Ikiform l'ignore.
Une longue réponse disparaît après une erreur dans Next.jsReact réinitialise le formulaire après une server action. Renvoyez les réponses depuis l'action et définissez defaultValue sur chaque input, comme le fait l'exemple Next.js.
Le script de notation lève 401 unauthorized ou 403 forbiddenUn 401 signifie que la clé est absente ou révoquée ; un 403 qu'elle est publiable ou n'a pas le scope. Utilisez une clé secrète (ik_sk_) avec le scope submissions:read.
La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivéElle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre Spam pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.