# Como montar um formulário de inscrição para beta que faz triagem dos candidatos

> Um formulário de candidatura ao beta com perguntas de triagem que o servidor exige, e um script que pontua os candidatos e escolhe cada turma da lista.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/waitlist/beta-signup

Faça algumas perguntas de triagem junto do e-mail e deixe o backend de formulários exigi-las, para que toda candidatura chegue completa e comparável. Depois escolha cada turma da lista com um script curto de pontuação, convide um grupo pequeno e deixe todos os outros esperando a próxima rodada.

Uma lista de espera manda e-mail para todo mundo no dia do lançamento. Um beta faz o contrário: você deixa entrar vinte pessoas, aprende com elas e depois deixa entrar mais vinte. Isso muda o formulário. Você precisa de respostas pelas quais dê para ordenar, não só de um endereço.

## O que muda num beta

- **É uma seleção.** Você convida poucas pessoas por vez, então precisa de um jeito de comparar os candidatos entre si. Escolhas fechadas, como função e tamanho da equipe, ordenam bem. Texto livre não.
- **Candidaturas incompletas custam uma vaga.** Se alguém pula a pergunta que diz se a pessoa se encaixa, você não consegue escolhê-la nem descartá-la. Torne as perguntas de triagem obrigatórias e faça o servidor recusar envios que as pulem.
- **Os convites saem em rodadas.** Sua lista precisa lembrar quem você já convidou, para a próxima turma pular essas pessoas. O Ikiform não tem tags, então esse estado fica no seu script ou na sua planilha.
- **Quem não foi escolhido ainda conta.** Essas pessoas são a sua próxima turma. Diga na frase de agradecimento que os grupos são chamados ao longo do tempo, para que uma caixa de entrada silenciosa não seja lida como um não.

## Perguntas de triagem

Seis perguntas é o teto. Cada uma deve mudar quem você convida; se você não sabe dizer como uma resposta faria alguém subir ou descer, corte. Experimente o formulário abaixo. Ele roda a mesma definição de campos que o Ikiform aplica no servidor, então você vê o `422` que uma candidatura incompleta recebe.

Formulário: Beta application

- **E-mail de trabalho** (`email`, email, obrigatório)
- **Sua função** (`role`, select, obrigatório)
- **Tamanho da equipe** (`team_size`, radio, obrigatório)
- **Para que você usaria primeiro?** (`use_case`, textarea, obrigatório)
- **O que você usa hoje?** (`current_tool`, text)
- **Topo uma conversa de 20 minutos** (`interview`, checkbox)

Cole esta definição na aba **Fields** do formulário. A resposta `use_case` precisa de pelo menos 40 caracteres, o que filtra candidaturas de uma palavra só.

| Campo | O que ela decide | Por que este tipo |
| --- | --- | --- |
| `email` | Quem convidar | Obrigatório. Um e-mail de trabalho dá uma pista da empresa sem perguntar |
| `role` | Se a pessoa sente o problema que você resolve | Um `select` com as suas próprias faixas, para você poder dar pesos |
| `team_size` | Se a sua primeira versão serve para o cenário dela | Um `radio`: uma resposta, sempre comparável |
| `use_case` | Se a pessoa vai usar o beta este mês | Um `textarea` com tamanho mínimo. O sinal mais honesto do formulário |
| `current_tool` | Quem você está substituindo, e com o que vão comparar você | Texto livre opcional |
| `interview` | Com quem você pode conversar | Um `checkbox`. Ausente significa `false`, então ele nunca bloqueia uma candidatura |

> Com uma definição de campos, o Ikiform recusa qualquer campo que não conhece com `422` e um erro `unknown_field`. Se você adicionar um input oculto como `ref`, adicione-o também à definição.

## O formulário

Todas as versões abaixo mostram a mensagem do servidor abaixo do campo a que ela se refere, porque um formulário de triagem falha por mais motivos do que um e-mail inválido. Cada uma mantém o que a pessoa digitou quando um envio é recusado, para ninguém redigitar um parágrafo.

**HTML**

O `required` e o `minlength` nativos pegam a maioria das lacunas antes do envio. O formulário simples funciona sem script e cai na sua página de agradecimento; o script mantém as pessoas na página e lista cada erro de campo.

`beta.html`:

```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>
```

`Antes de </body>`:

```html
<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>
```

**Next.js**

O React reinicia um formulário depois que uma server action roda, o que apagaria uma resposta longa num `422`. A action devolve as respostas como `values`, e cada input as lê de volta via `defaultValue`.

`app/beta/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface ApplyState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string>;
}

export async function apply(_previous: ApplyState, formData: FormData): Promise<ApplyState> {
  const answers = Object.fromEntries(
    [...formData].filter((entry): entry is [string, string] => typeof entry[1] === "string" && !entry[0].startsWith("$ACTION")),
  );
  const origin = (await headers()).get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way." };
  return { ok: false, message: result?.error?.message ?? "Couldn't send your application. Try again.", fields: result?.error?.fields, values: answers };
}
```

`app/beta/beta-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type ApplyState, apply } from "./actions";

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

export function BetaForm() {
  const [state, action, pending] = useActionState(apply, { ok: false, message: "" } as ApplyState);
  const errors = state.fields ?? {};
  const values = state.values ?? {};

  if (state.ok) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required defaultValue={values.role}>
        <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>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required defaultChecked={values.team_size === size} /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required defaultValue={values.use_case} />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} defaultValue={values.current_tool} />
      <label>
        <input type="checkbox" name="interview" value="true" defaultChecked={values.interview === "true"} /> I'm open to a 20-minute call
      </label>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
    </form>
  );
}
```

**Astro**

Sem um schema `input`, uma Astro Action recebe o `FormData` cru e o Ikiform faz a validação. O handler devolve os erros de campo como dados em vez de lançar exceção, para a página poder posicionar cada mensagem.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";

export const server = {
  apply: defineAction({
    accept: "form",
    handler: async (formData: FormData, context) => {
      const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(answers),
      });
      const result = await response.json();
      if (result.ok) return { ok: true, fields: {} as Record<string, string[]>, message: "" };
      return { ok: false, fields: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) };
    },
  }),
};
```

`src/pages/beta.astro`:

```astro
---
import { actions } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.apply)?.data;
const errors = result?.fields ?? {};
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
---

{result?.ok ? (
  <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
) : (
  <form method="POST" action={actions.apply}>
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {errors.email && <p role="alert">{errors.email[0]}</p>}
    <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>
      {sizes.map(([size, label]) => <label><input type="radio" name="team_size" value={size} required /> {label}</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>
    {errors.use_case && <p role="alert">{errors.use_case[0]}</p>}
    <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 />
    {result && !result.ok && !Object.keys(errors).length && <p role="alert">{result.message}</p>}
    <button>Apply for the beta</button>
  </form>
)}
```

**TanStack Start**

A server function encaminha cada entrada de texto. O formulário envia via `onSubmit`, então os inputs mantêm os valores quando o servidor responde com erros.

`src/routes/beta.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

const applyForBeta = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return Object.fromEntries([...data].filter((entry): entry is [string, string] => typeof entry[1] === "string"));
  })
  .handler(async ({ data: answers }) => {
    const origin = getRequestHeader("origin");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(answers),
    });
    const result = await response.json();
    if (result.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} };
    return { ok: false, message: String(result.error.message), fields: (result.error.fields ?? {}) as Record<string, string[]> };
  });

export const Route = createFileRoute("/beta")({ component: Beta });

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

function Beta() {
  const applyFn = useServerFn(applyForBeta);
  const [state, setState] = useState<{ ok: boolean; message: string; fields: Record<string, string[]> } | null>(null);
  const [pending, setPending] = useState(false);
  const errors = state?.fields ?? {};

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setState(await applyFn({ data: new FormData(event.currentTarget) }));
    } catch {
      setState({ ok: false, message: "Couldn't send your application. Try again.", fields: {} });
    } finally {
      setPending(false);
    }
  }

  if (state?.ok) return <p role="status">{state.message}</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="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>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="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 />
      {state && !Object.keys(errors).length ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
    </form>
  );
}
```

**React Router**

A `action` da rota encaminha o envio e mantém o status do Ikiform com `data()`. O `<Form>` envia sem recarregar, então as respostas continuam no lugar.

`app/routes/beta.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/beta";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
  const origin = request.headers.get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} };
  return data(
    { ok: false, message: String(result.error.message), fields: (result.error.fields ?? {}) as Record<string, string[]> },
    { status: response.status },
  );
}

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

export default function Beta({ actionData }: Route.ComponentProps) {
  const pending = useNavigation().state === "submitting";
  const errors: Record<string, string[]> = actionData?.fields ?? {};

  if (actionData?.ok) return <p role="status">{actionData.message}</p>;

  return (
    <Form method="post">
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="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>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="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 />
      {actionData && !Object.keys(errors).length ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
    </Form>
  );
}
```

**SvelteKit**

`fail()` devolve os erros de campo para `form`. O `use:enhance` só reinicia o formulário depois de um sucesso, então uma candidatura recusada mantém as respostas. Declare `IKIFORM_SECRET_KEY` em `src/env.ts` com `defineEnvVars`, como na [lista de espera com SvelteKit](https://ikiform.com/pt-br/guides/waitlist/sveltekit.md#env), para que `$app/env/private` consiga lê-la, e repasse o `Origin` do navegador para os testes locais contarem como envios gratuitos de `localhost`.

`src/routes/beta/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const formData = await request.formData();
    const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify(answers),
    });
    const result = await response.json();
    if (!result.ok) {
      return fail(response.status, {
        message: String(result.error.message),
        fields: (result.error.fields ?? {}) as Record<string, string[]>,
      });
    }
    return { applied: true };
  },
} satisfies Actions;
```

`src/routes/beta/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
</script>

{#if form?.applied}
  <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
{:else}
  <form method="POST" use:enhance>
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {#if form?.fields?.email}<p role="alert">{form.fields.email[0]}</p>{/if}
    <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>
      {#each sizes as [size, label]}
        <label><input type="radio" name="team_size" value={size} required /> {label}</label>
      {/each}
    </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>
    {#if form?.fields?.use_case}<p role="alert">{form.fields.use_case[0]}</p>{/if}
    <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 />
    {#if form?.message && !form?.fields?.email && !form?.fields?.use_case}<p role="alert">{form.message}</p>{/if}
    <button>Apply for the beta</button>
  </form>
{/if}
```

**Nuxt**

A server route lê o `FormData` com `readFormData` e repassa o status do Ikiform. `ignoreResponseError` faz o `$fetch` devolver o corpo do erro em vez de lançar exceção.

`server/api/beta.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const formData = await readFormData(event);
  const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
  const { ikiformSecretKey } = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json();
  setResponseStatus(event, response.status);
  return result.ok
    ? { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} }
    : { ok: false, message: String(result.error.message), fields: result.error.fields ?? {} };
});
```

`app/components/BetaForm.vue`:

```vue
<script setup lang="ts">
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
const state = ref<{ ok: boolean; message: string; fields: Record<string, string[]> } | null>(null);
const pending = ref(false);

async function apply(event: SubmitEvent) {
  pending.value = true;
  try {
    state.value = await $fetch("/api/beta", {
      method: "POST",
      body: new FormData(event.target as HTMLFormElement),
      ignoreResponseError: true,
    });
  } catch {
    state.value = { ok: false, message: "Couldn't send your application. Try again.", fields: {} };
  }
  pending.value = false;
}
</script>

<template>
  <p v-if="state?.ok" role="status">{{ state.message }}</p>
  <form v-else method="post" @submit.prevent="apply">
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <p v-if="state?.fields.email" role="alert">{{ state.fields.email[0] }}</p>
    <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 v-for="[size, label] in sizes" :key="size">
        <input type="radio" name="team_size" :value="size" required /> {{ label }}
      </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 />
    <p v-if="state?.fields.use_case" role="alert">{{ state.fields.use_case[0] }}</p>
    <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 />
    <button :disabled="pending">{{ pending ? "Sending" : "Apply for the beta" }}</button>
  </form>
</template>
```

**React**

Uma single-page app envia direto ao Ikiform a partir do navegador, então não há chave secreta neste código. Mantenha **Allowed domains** definido com o seu site.

`src/BetaForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

export function BetaForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "applied">("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    setMessage("");
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) return setStatus("applied");
      setErrors(result.error.fields ?? {});
      setMessage(result.error.message);
    } catch {
      setMessage("Couldn't reach the server. Try again.");
    }
    setStatus("idle");
  }

  if (status === "applied") return <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="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>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="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 />
      {message && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Apply for the beta"}</button>
    </form>
  );
}
```

**Vue**

O mesmo envio pelo navegador num componente Vue. O formulário mantém o `action`, então ainda envia se o script falhar.

`src/components/BetaForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
const status = ref<"idle" | "sending" | "applied">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");

async function apply(event: SubmitEvent) {
  status.value = "sending";
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    if (result.ok) {
      status.value = "applied";
      return;
    }
    errors.value = result.error.fields ?? {};
    message.value = result.error.message;
  } catch {
    message.value = "Couldn't reach the server. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'applied'" role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="apply">
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <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 v-for="[size, label] in sizes" :key="size">
        <input type="radio" name="team_size" :value="size" required /> {{ label }}
      </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 />
    <p v-if="errors.use_case" role="alert">{{ errors.use_case[0] }}</p>
    <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 />
    <p v-if="message && !Object.keys(errors).length" role="alert">{{ message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Apply for the beta" }}</button>
  </form>
</template>
```

> As versões no servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha `IKIFORM_SECRET_KEY` em variáveis de ambiente só do servidor.

## Escolha uma turma

Diagrama: Como as candidaturas viram turmas do beta

1. As candidaturas chegam completas, porque o Ikiform recusa envios que pulam uma pergunta obrigatória.
2. Um script as lê pela API e dá a cada uma uma pontuação por função, tamanho da equipe, tamanho do caso de uso e o checkbox da conversa.
3. As 25 primeiras viram a primeira turma e recebem um convite pela sua ferramenta de e-mail.
4. Duas semanas depois, rode o script de novo com os e-mails convidados, para que sejam pulados, e escolha o próximo grupo.
5. Todos abaixo do corte continuam na lista e têm notícias suas quando chegar a rodada deles.

As pontuações são uma ordem inicial, não um veredito. Leia você mesmo o caso de uso de todos que estão perto da linha de corte.

O script lê todas as candidaturas recebidas com a [API REST](https://ikiform.com/pt-br/docs/api.md#list-submissions), mantém a mais nova por e-mail, dá uma pontuação e grava o grupo do topo em `cohort.csv`. Passe os e-mails que você já convidou como argumentos para a próxima rodada pulá-los. Ele precisa de uma chave secreta com o escopo `submissions:read`.

`cohort.mjs`:

```js
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}`);
```

- Mude `ROLE` e `SIZE` de acordo com o público da sua primeira versão. Uma ferramenta para equipes pode dar a maior pontuação a `2-10`; uma ferramenta individual, a `1`.
- O caso de uso ganha um ponto a cada 25 palavras, até 3. Tamanho não é qualidade, mas quem escreve três frases costuma aparecer.
- Colocar cada valor entre aspas evita que vírgulas nas respostas quebrem o CSV.
- Guarde a lista de convidados num lugar fácil de achar, como um `invited.txt` que você passa com `$(cat invited.txt)`.

### Envie o convite

Importe o `cohort.csv` na sua ferramenta de e-mail e envie o convite de lá. O Ikiform não envia e-mail para quem preenche o formulário. Os e-mails de notificação vão só para você e sua equipe, então não há resposta automática, e-mail de confirmação nem double opt-in. Envie esses e-mails pela sua ferramenta de e-mail, disparados por um [webhook](https://ikiform.com/pt-br/docs/webhooks.md). Mantenha o primeiro convite curto: o que a pessoa ganha, a única coisa que você quer que ela teste e como falar com você. Cada e-mail de notificação que o Ikiform manda para você tem um **Reply** que vai para o candidato, o que serve bem para uma mensagem pessoal rápida a quem está perto da linha de corte.

## Confira se funciona

1. Em **Allowed domains** do formulário, adicione o domínio do seu site, mais `localhost` enquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar.
2. Cole a definição de campos na aba **Fields** do formulário e salve.
3. Envie uma candidatura com um caso de uso de uma linha. O formulário mostra a mensagem de tamanho mínimo abaixo desse campo, e nada é armazenado.
4. Envie uma candidatura completa. Ela aparece em **Submissions** com todas as respostas, e `interview` mostra `true` ou `false`.
5. Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e **Reply** vai para quem enviou quando o formulário tem um campo `email`.
6. Envie com o honeypot preenchido, `_gotcha=bot`. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook.
7. Rode o script de pontuação com uma chave secreta e confira se o `cohort.csv` lista a sua candidatura de teste com a pontuação dela.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `422 validation_failed` | O envio não corresponde à definição de campos do formulário. `error.fields` aponta cada campo e o motivo. Faça o `name` do input bater com o id do campo. |
| `422` com Unknown field para `ref`, `utm_source` ou outro input oculto | A definição de campos não o lista. Adicione-o como um campo `text` opcional, ou renomeie-o com um `_` no início para o Ikiform descartá-lo. |
| Uma resposta longa some depois de um erro no Next.js | O React reinicia o formulário depois de uma server action. Devolva as respostas a partir da action e defina `defaultValue` em cada input, como o exemplo de Next.js faz. |
| O script de pontuação lança `401 unauthorized` ou `403 forbidden` | Um `401` significa que a chave está ausente ou foi revogada; um `403` significa que ela é publicável ou não tem o escopo. Use uma chave secreta (`ik_sk_`) com o escopo `submissions:read`. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro **Spam** para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |

## Perguntas

### Quantas perguntas um formulário de inscrição para beta deve ter?

De três a seis. Cada pergunta deve mudar quem você convida. Escolhas fechadas obrigatórias, como função e tamanho da equipe, ordenam bem; uma pergunta aberta obrigatória com tamanho mínimo mostra quem vai usar o beta.

### O Ikiform pode rejeitar candidaturas que pulam uma pergunta de triagem?

Sim. Defina os campos na aba Fields do formulário e marque-os como obrigatórios. Um envio sem um deles é recusado com 422 validation_failed, e o erro aponta cada campo, para que o seu formulário possa mostrar a mensagem no lugar.

### Posso marcar candidatos como convidados no Ikiform?

Não. O Ikiform não tem tags nem status personalizados. Mantenha a lista de convidados onde você envia os convites, ou num arquivo que você passa ao script de pontuação, como mostrado acima.

### O Ikiform remove envios duplicados?

Não. Todo envio aceito é armazenado. Uma repetição idêntica em até 24 horas aumenta a pontuação de spam, mas não é spam por si só, então remova os duplicados por e-mail na sua lista, no banco de dados ou na exportação CSV.

### O Ikiform é gratuito?

Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.

## Mais guias

- [Lista de espera para um novo SaaS](https://ikiform.com/pt-br/guides/waitlist.md): Monte uma lista de espera pré-lançamento na sua própria landing page: o formulário, proteção contra spam, duplicados, e-mails de confirmação e uma exportação limpa no dia do lançamento.
- [Lista de espera para app mobile](https://ikiform.com/pt-br/guides/waitlist/mobile-app.md): Uma lista de espera pré-lançamento para um app iOS ou Android que pergunta qual celular a pessoa usa, manda testadores de iPhone ao TestFlight e preenche um teste fechado do Play.
- [Lista para lançamento no Product Hunt](https://ikiform.com/pt-br/guides/waitlist/product-hunt.md): Colete uma lista semanas antes do Product Hunt, marque cada inscrição pelo canal e envie o e-mail do dia do lançamento em duas ondas, no ritmo da virada do horário do Pacífico.
- [Lista de espera com Next.js](https://ikiform.com/pt-br/guides/waitlist/nextjs.md): Uma lista de espera no App Router do Next.js: uma server action que encaminha as inscrições com chave secreta, useActionState, agradecimento otimista e limite por visitante.
- [Lista de espera no Framer](https://ikiform.com/pt-br/guides/waitlist/framer.md): Duas formas de coletar inscrições na lista de espera no Framer: o formulário do Framer com webhook para o Ikiform, ou um code component que mantém os limites por visitante e as verificações de spam.
- [Lista de espera no Webflow](https://ikiform.com/pt-br/guides/waitlist/webflow.md): Aponte um Form Block do Webflow para o Ikiform, marque as inscrições pela origem com um campo oculto e mantenha o estado de sucesso do Webflow com um script curto para todos os formulários.
